From 4638e0f526d1a260907a95145f4d70352b6aba2d Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 29 Apr 2026 23:15:03 +0800 Subject: [PATCH 01/17] fix(update): check valid update files Signed-off-by: fufesou --- .../lib/desktop/widgets/update_progress.dart | 220 ++++---- src/flutter_ffi.rs | 61 ++- src/hbbs_http/account.rs | 2 +- src/hbbs_http/downloader.rs | 2 +- src/hbbs_http/http_client.rs | 20 +- src/hbbs_http/record_upload.rs | 2 +- src/platform/macos.rs | 501 ++++++++++++++---- src/platform/privileges_scripts/update.scpt | 14 +- src/platform/windows.rs | 451 +++++++++++++++- src/updater.rs | 480 ++++++++++++++++- 10 files changed, 1484 insertions(+), 269 deletions(-) diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index 93f661b7b..bde306316 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -7,10 +7,10 @@ import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher.dart'; -final _isExtracting = false.obs; +const _eventKeyUpdateMe = 'update-me'; +const _eventKeyUpdateMeReady = 'update-me-ready'; void handleUpdate(String releasePageUrl) { - _isExtracting.value = false; String downloadUrl = releasePageUrl.replaceAll('tag', 'download'); String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); final String downloadFile = @@ -23,46 +23,112 @@ void handleUpdate(String releasePageUrl) { } downloadUrl = '$downloadUrl/$downloadFile'; - SimpleWrapper downloadId = SimpleWrapper(''); + SimpleWrapper downloadId = SimpleWrapper(''); SimpleWrapper onCanceled = SimpleWrapper(() {}); + SimpleWrapper pendingCancel = SimpleWrapper(false); + SimpleWrapper Function()> cancelDownload = + SimpleWrapper(() async {}); gFFI.dialogManager.dismissAll(); gFFI.dialogManager.show((setState, close, context) { + cancelDownload.value = () async { + final id = downloadId.value; + if (id.isEmpty) { + pendingCancel.value = true; + return; + } + pendingCancel.value = false; + onCanceled.value(); + await bind.mainSetCommon(key: 'cancel-downloader', value: id); + // Wait for the downloader to be removed. + for (int i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 300)); + final isCanceled = 'error:Downloader not found' == + await bind.mainGetCommon(key: 'download-data-$id'); + if (isCanceled) { + break; + } + } + close(); + }; return CustomAlertDialog( - title: Obx(() => Text(translate(_isExtracting.isTrue - ? 'Preparing for installation ...' - : 'Downloading {$appName}'))), - content: - UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled) - .marginSymmetric(horizontal: 8) - .paddingOnly(top: 12), + title: Text(translate('Downloading {$appName}')), + content: UpdateProgress(releasePageUrl, downloadUrl, downloadId, + onCanceled, pendingCancel, cancelDownload) + .marginSymmetric(horizontal: 8) + .paddingOnly(top: 12), actions: [ - if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async { - onCanceled.value(); - await bind.mainSetCommon( - key: 'cancel-downloader', value: downloadId.value); - // Wait for the downloader to be removed. - for (int i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 300)); - final isCanceled = 'error:Downloader not found' == - await bind.mainGetCommon( - key: 'download-data-${downloadId.value}'); - if (isCanceled) { - break; - } - } - close(); + dialogButton(translate('Cancel'), onPressed: () async { + await cancelDownload.value(); }, isOutline: true), ]); }); } +void _showUpdateError(String releasePageUrl, String error, + {bool showRetry = true}) { + debugPrint('Update error: $error'); + final dialogManager = gFFI.dialogManager; + + jumplink() { + launchUrl(Uri.parse(releasePageUrl)); + dialogManager.dismissAll(); + } + + retry() { + dialogManager.dismissAll(); + handleUpdate(releasePageUrl); + } + + dialogManager.dismissAll(); + dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: null, + content: SelectionArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + msgboxContent('custom-nocancel-nook-hasclose', 'Error', + 'download-new-version-failed-tip'), + const SizedBox(height: 8), + Text(error), + ], + ), + ), + actions: [ + dialogButton('Download', onPressed: jumplink), + if (showRetry) dialogButton('Retry', onPressed: retry), + dialogButton('Close', onPressed: close), + ], + ), + tag: 'custom-nocancel-nook-hasclose-Error-Error', + ); +} + +void _showPreparingForInstallation() { + gFFI.dialogManager.dismissAll(); + gFFI.dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: Text(translate('Preparing for installation ...')), + content: const LinearProgressIndicator( + minHeight: 20, + borderRadius: BorderRadius.all(Radius.circular(5)), + ).marginSymmetric(horizontal: 8).paddingOnly(top: 12), + actions: const [], + ), + tag: 'preparing-for-installation', + ); +} + class UpdateProgress extends StatefulWidget { final String releasePageUrl; final String downloadUrl; - final SimpleWrapper downloadId; - final SimpleWrapper onCanceled; - UpdateProgress( - this.releasePageUrl, this.downloadUrl, this.downloadId, this.onCanceled, + final SimpleWrapper downloadId; + final SimpleWrapper onCanceled; + final SimpleWrapper pendingCancel; + final SimpleWrapper Function()> cancelDownload; + UpdateProgress(this.releasePageUrl, this.downloadUrl, this.downloadId, + this.onCanceled, this.pendingCancel, this.cancelDownload, {Key? key}) : super(key: key); @@ -76,7 +142,6 @@ class UpdateProgressState extends State { int _downloadedSize = 0; int _getDataFailedCount = 0; final String _eventKeyDownloadNewVersion = 'download-new-version'; - final String _eventKeyExtractUpdateDmg = 'extract-update-dmg'; @override void initState() { @@ -88,11 +153,6 @@ class UpdateProgressState extends State { _eventKeyDownloadNewVersion, handleDownloadNewVersion, replace: true); bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl); - if (isMacOS) { - platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg, - _eventKeyExtractUpdateDmg, handleExtractUpdateDmg, - replace: true); - } } @override @@ -100,10 +160,6 @@ class UpdateProgressState extends State { cancelQueryTimer(); platformFFI.unregisterEventHandler( _eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion); - if (isMacOS) { - platformFFI.unregisterEventHandler( - _eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg); - } super.dispose(); } @@ -118,6 +174,9 @@ class UpdateProgressState extends State { _timer = Timer.periodic(const Duration(milliseconds: 300), (timer) { _updateDownloadData(); }); + if (widget.pendingCancel.value) { + await widget.cancelDownload.value(); + } } else { if (evt.containsKey('error')) { _onError(evt['error'] as String); @@ -128,47 +187,9 @@ class UpdateProgressState extends State { } } - // `isExtractDmg` is true when handling extract-update-dmg event. - // It's a rare case that the dmg file is corrupted and cannot be extracted. - void _onError(String error, {bool isExtractDmg = false}) { + void _onError(String error) { cancelQueryTimer(); - - debugPrint( - '${isExtractDmg ? "Extract" : "Download"} new version error: $error'); - final msgBoxType = 'custom-nocancel-nook-hasclose'; - final msgBoxTitle = 'Error'; - final msgBoxText = 'download-new-version-failed-tip'; - final dialogManager = gFFI.dialogManager; - - close() { - dialogManager.dismissAll(); - } - - jumplink() { - launchUrl(Uri.parse(widget.releasePageUrl)); - dialogManager.dismissAll(); - } - - retry() { - dialogManager.dismissAll(); - handleUpdate(widget.releasePageUrl); - } - - final List buttons = [ - dialogButton('Download', onPressed: jumplink), - if (!isExtractDmg) dialogButton('Retry', onPressed: retry), - dialogButton('Close', onPressed: close), - ]; - dialogManager.dismissAll(); - dialogManager.show( - (setState, close, context) => CustomAlertDialog( - title: null, - content: SelectionArea( - child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)), - actions: buttons, - ), - tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle', - ); + _showUpdateError(widget.releasePageUrl, error); } void _updateDownloadData() { @@ -212,13 +233,7 @@ class UpdateProgressState extends State { _onError('The download file size is 0.'); } else { setState(() {}); - if (isMacOS) { - bind.mainSetCommon( - key: 'extract-update-dmg', value: widget.downloadUrl); - _isExtracting.value = true; - } else { - updateMsgBox(); - } + updateMsgBox(); } } else { setState(() {}); @@ -236,28 +251,41 @@ class UpdateProgressState extends State { gFFI.dialogManager, onSubmit: () { debugPrint('Downloaded, update to new version now'); + if (isMacOS) { + _showPreparingForInstallation(); + platformFFI.registerEventHandler( + _eventKeyUpdateMeReady, _eventKeyUpdateMeReady, (evt) async { + platformFFI.unregisterEventHandler( + _eventKeyUpdateMeReady, _eventKeyUpdateMeReady); + gFFI.dialogManager.dismissAll(); + }, replace: true); + } + platformFFI.registerEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe, + (evt) async { + platformFFI.unregisterEventHandler( + _eventKeyUpdateMe, _eventKeyUpdateMe); + if (isMacOS) { + platformFFI.unregisterEventHandler( + _eventKeyUpdateMeReady, _eventKeyUpdateMeReady); + } + if (evt.containsKey('error')) { + _showUpdateError(widget.releasePageUrl, evt['error'] as String, + showRetry: false); + } + }, replace: true); bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl); }, submitTimeout: 5, ); } - Future handleExtractUpdateDmg(Map evt) async { - _isExtracting.value = false; - if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) { - _onError(evt['err'] as String, isExtractDmg: true); - } else { - updateMsgBox(); - } - } - @override Widget build(BuildContext context) { getValue() => _totalSize == null ? 0.0 : (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!); return LinearProgressIndicator( - value: _isExtracting.isTrue ? null : getValue(), + value: getValue(), minHeight: 20, borderRadius: BorderRadius.circular(5), backgroundColor: Colors.grey[300], diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 1ee13f4df..a0fb31260 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2831,6 +2831,24 @@ pub fn main_get_common_sync(key: String) -> SyncReturn { SyncReturn(main_get_common(key)) } +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn push_update_me_error(error: String) { + let data = HashMap::from([("name", "update-me".to_owned()), ("error", error)]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); +} + +#[cfg(target_os = "macos")] +fn push_update_me_ready() { + let data = HashMap::from([("name", "update-me-ready".to_owned())]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); +} + pub fn main_set_common(_key: String, _value: String) { #[cfg(target_os = "windows")] if _key == "install-printer" && crate::platform::is_win_10_or_greater() { @@ -2899,34 +2917,41 @@ pub fn main_set_common(_key: String, _value: String) { if let Some(f) = new_version_file.to_str() { // 1.4.0 does not support "--update" // But we can assume that the new version supports it. - #[cfg(any(target_os = "windows", target_os = "macos"))] - match crate::platform::update_to(f) { + let expected_sha256 = + match crate::updater::download_file_expected_sha256(&_value) { + Ok(expected_sha256) => expected_sha256, + Err(e) => { + let error = format!("Failed to get new version file SHA256, {}", e); + log::error!("{}", error); + push_update_me_error(error); + fs::remove_file(f).ok(); + return; + } + }; + + #[cfg(target_os = "windows")] + let update_res = crate::platform::update_to_verified(f, &expected_sha256); + #[cfg(target_os = "macos")] + let update_res = crate::platform::macos::update_to_verified_dmg( + f, + &expected_sha256, + Some(push_update_me_ready), + ); + #[cfg(any(target_os = "windows", target_os = "macos"))] + match update_res { Ok(_) => { log::info!("Update process is launched successfully!"); } Err(e) => { - log::error!("Failed to update to new version, {}", e); + let error = format!("Failed to update to new version, {}", e); + log::error!("{}", error); + push_update_me_error(error); fs::remove_file(f).ok(); } } } } - } else if _key == "extract-update-dmg" { - #[cfg(target_os = "macos")] - { - if let Some(new_version_file) = get_download_file_from_url(&_value) { - if let Some(f) = new_version_file.to_str() { - crate::platform::macos::extract_update_dmg(f); - } else { - // unreachable!() - log::error!("Failed to get the new version file path"); - } - } else { - // unreachable!() - log::error!("Failed to get the new version file from url: {}", _value); - } - } } } diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 3f824113b..5d21db9c9 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -153,7 +153,7 @@ impl OidcSession { } // This URL is used to detect the appropriate TLS implementation for the server. let login_option_url = format!("{}/api/login-options", api_server); - let _ = create_http_client_with_url(&login_option_url); + let _ = create_http_client_with_url(&login_option_url, None); write_guard.warmed_api_server = Some(api_server.to_owned()); } diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index 573e7e77c..a1b16590f 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -167,7 +167,7 @@ async fn do_download( auto_del_dur: Option, mut rx_cancel: UnboundedReceiver<()>, ) -> ResultType { - let client = create_http_client_async_with_url(&url).await; + let client = create_http_client_async_with_url(&url, Some(false)).await; let mut is_all_downloaded = false; tokio::select! { diff --git a/src/hbbs_http/http_client.rs b/src/hbbs_http/http_client.rs index 432e5fa38..bd732bfcd 100644 --- a/src/hbbs_http/http_client.rs +++ b/src/hbbs_http/http_client.rs @@ -120,19 +120,23 @@ pub fn get_url_for_tls<'a>(url: &'a str, proxy_conf: &'a Option) - url } -pub fn create_http_client_with_url(url: &str) -> SyncClient { +pub fn create_http_client_with_url( + url: &str, + tls_danger_accept_invalid_cert: Option, +) -> SyncClient { let proxy_conf = Config::get_socks(); let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); let is_tls_type_cached = tls_type.is_some(); let tls_type = tls_type.unwrap_or(TlsType::Rustls); - let tls_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let danger_accept_invalid_cert = + tls_danger_accept_invalid_cert.or_else(|| get_cached_tls_accept_invalid_cert(tls_url)); create_http_client_with_url_( url, tls_url, tls_type, is_tls_type_cached, - tls_danger_accept_invalid_cert, + danger_accept_invalid_cert, tls_danger_accept_invalid_cert, ) } @@ -229,20 +233,24 @@ fn create_http_client_with_url_( client } -pub async fn create_http_client_async_with_url(url: &str) -> AsyncClient { +pub async fn create_http_client_async_with_url( + url: &str, + tls_danger_accept_invalid_cert: Option, +) -> AsyncClient { let proxy_conf = Config::get_socks(); let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); let is_tls_type_cached = tls_type.is_some(); let tls_type = tls_type.unwrap_or(TlsType::Rustls); - let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let danger_accept_invalid_cert = + tls_danger_accept_invalid_cert.or_else(|| get_cached_tls_accept_invalid_cert(tls_url)); create_http_client_async_with_url_( url, tls_url, tls_type, is_tls_type_cached, danger_accept_invalid_cert, - danger_accept_invalid_cert, + tls_danger_accept_invalid_cert, ) .await } diff --git a/src/hbbs_http/record_upload.rs b/src/hbbs_http/record_upload.rs index ac51d5c32..b841368d4 100644 --- a/src/hbbs_http/record_upload.rs +++ b/src/hbbs_http/record_upload.rs @@ -32,7 +32,7 @@ pub fn run(rx: Receiver) { ); // This URL is used for TLS connectivity testing and fallback detection. let login_option_url = format!("{}/api/login-options", &api_server); - let client = create_http_client_with_url(&login_option_url); + let client = create_http_client_with_url(&login_option_url, None); let mut uploader = RecordUploader { client, api_server, diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 2e68cf5d8..71696dde2 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -28,11 +28,11 @@ use objc::rc::autoreleasepool; use objc::{class, msg_send, sel, sel_impl}; use scrap::{libc::c_void, quartz::ffi::*}; use std::{ - collections::HashMap, - os::unix::process::CommandExt, + io::{Read, Seek, Write}, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, - process::{Command, Stdio}, - sync::Mutex, + process::Command, + sync::{Mutex, OnceLock}, }; // macOS boolean_t is defined as `int` in @@ -41,11 +41,23 @@ type BooleanT = hbb_common::libc::c_int; static PRIVILEGES_SCRIPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); static mut LATEST_SEED: i32 = 0; +static UPDATE_TEMP_DIR: OnceLock = OnceLock::new(); +const UPDATE_TEMP_DMG_CREATE_ATTEMPTS: usize = 16; +const UPDATE_DMG_MOUNT_POINT: &str = "/Volumes/RustDeskUpdate"; #[inline] fn get_update_temp_dir() -> PathBuf { + UPDATE_TEMP_DIR.get_or_init(new_update_temp_dir).clone() +} + +fn new_update_temp_dir() -> PathBuf { let euid = unsafe { hbb_common::libc::geteuid() }; - Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) + Path::new("/tmp").join(format!( + ".rustdeskupdate-{}-{}-{}", + euid, + std::process::id(), + hbb_common::rand::random::() + )) } #[inline] @@ -53,6 +65,39 @@ fn get_update_temp_dir_string() -> String { get_update_temp_dir().to_string_lossy().into_owned() } +fn get_update_temp_dmg_dir() -> PathBuf { + get_update_temp_dir().join("dmgdir") +} + +fn create_update_temp_dmg_file() -> ResultType<(std::fs::File, PathBuf)> { + let dmg_dir = get_update_temp_dmg_dir(); + std::fs::create_dir_all(&dmg_dir)?; + std::fs::set_permissions(&dmg_dir, std::fs::Permissions::from_mode(0o700))?; + + for _ in 0..UPDATE_TEMP_DMG_CREATE_ATTEMPTS { + let file_path = dmg_dir.join(format!( + "{}-{}-{}.dmg", + crate::get_app_name(), + std::process::id(), + hbb_common::rand::random::() + )); + let file_res = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&file_path); + match file_res { + Ok(file) => { + return Ok((file, file_path)); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + + bail!("Failed to create update DMG file"); +} + /// Global mutex to serialize CoreGraphics cursor operations. /// This prevents race conditions between cursor visibility (hide depth tracking) /// and cursor positioning/clipping operations. @@ -250,29 +295,35 @@ pub fn is_installed_daemon(prompt: bool) -> bool { false } -fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync: bool) { +fn update_daemon_agent( + agent_plist_file: String, + update_source_dir: String, + sync: bool, + keep_current_process: bool, +) -> ResultType<()> { let update_script_file = "update.scpt"; let Some(update_script) = PRIVILEGES_SCRIPTS_DIR.get_file(update_script_file) else { - return; + bail!("Failed to find {}", update_script_file); }; let Some(update_script_body) = update_script.contents_utf8().map(correct_app_name) else { - return; + bail!("Failed to read {}", update_script_file); }; let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else { - return; + bail!("Failed to find daemon.plist"); }; let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { - return; + bail!("Failed to read daemon.plist"); }; let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { - return; + bail!("Failed to find agent.plist"); }; let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { - return; + bail!("Failed to read agent.plist"); }; - let func = move || { + let current_pid = current_pid_for_update_script(keep_current_process); + let func = move || -> ResultType<()> { let mut binding = std::process::Command::new("osascript"); let cmd = binding .arg("-e") @@ -280,25 +331,31 @@ fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync .arg(daemon_plist_body) .arg(agent_plist_body) .arg(&get_active_username()) - .arg(std::process::id().to_string()) + .arg(¤t_pid) .arg(update_source_dir); - match cmd.status() { + match cmd.output() { Err(e) => { log::error!("run osascript failed: {}", e); + bail!("run osascript failed: {}", e); } - Ok(status) if !status.success() => { - log::warn!("run osascript failed with status: {}", status); + Ok(output) if !output.status.success() => { + log::warn!("run osascript failed with status: {}", output.status); + bail!("run osascript failed with status: {}", output.status); } _ => { let installed = std::path::Path::new(&agent_plist_file).exists(); log::info!("Agent file {} installed: {}", &agent_plist_file, installed); } } + Ok(()) }; if sync { - func(); + func() } else { - std::thread::spawn(func); + std::thread::spawn(move || { + hbb_common::allow_err!(func()); + }); + Ok(()) } } @@ -819,21 +876,53 @@ pub fn quit_gui() { #[inline] pub fn try_remove_temp_update_dir(dir: Option<&str>) { - let target_path_buf = dir.map(PathBuf::from).unwrap_or_else(get_update_temp_dir); - let target_path = target_path_buf.as_path(); - if target_path.exists() { - std::fs::remove_dir_all(target_path).ok(); + if let Some(dir) = dir { + remove_temp_update_dir(Path::new(dir)); + } else { + // `None` is startup stale-dir cleanup. Concurrent local updates are not supported here. + remove_current_user_temp_update_dirs(); } } -pub fn update_me() -> ResultType<()> { - let is_installed_daemon = is_installed_daemon(false); - let option_stop_service = "stop-service"; - let is_service_stopped = hbb_common::config::option2bool( - option_stop_service, - &crate::ui_interface::get_option(option_stop_service), - ); +fn remove_temp_update_dir(path: &Path) { + match std::fs::remove_dir_all(path) { + Ok(()) => {} + Err(e) + if e.kind() == std::io::ErrorKind::NotFound + || e.raw_os_error() == Some(hbb_common::libc::ENOTDIR) => {} + Err(e) => { + log::warn!("Failed to remove update temp dir {}: {}", path.display(), e); + } + } +} +fn remove_current_user_temp_update_dirs() { + let Ok(entries) = std::fs::read_dir("/tmp") else { + return; + }; + let current_temp_dir = get_update_temp_dir(); + for entry in entries.flatten() { + let path = entry.path(); + if path == current_temp_dir { + continue; + } + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + if is_current_user_update_temp_dir_name(file_name) { + remove_temp_update_dir(&path); + } + } +} + +fn is_current_user_update_temp_dir_name(file_name: &str) -> bool { + let euid = unsafe { hbb_common::libc::geteuid() }; + let prefix = format!(".rustdeskupdate-{}", euid); + file_name == prefix || file_name.starts_with(&format!("{}-", prefix)) +} + +pub fn update_me() -> ResultType<()> { let cmd = std::env::current_exe()?; // RustDesk.app/Contents/MacOS/RustDesk let app_dir = cmd @@ -844,44 +933,75 @@ pub fn update_me() -> ResultType<()> { let Some(app_dir) = app_dir else { bail!("Unknown app directory of current exe file: {:?}", cmd); }; + update_me_from_app_dir(app_dir, true) +} + +fn current_pid_for_update_script(keep_current_process: bool) -> String { + if keep_current_process { + std::process::id().to_string() + } else { + "0".to_owned() + } +} + +fn update_me_from_app_dir(app_dir: String, keep_current_process: bool) -> ResultType<()> { + let is_installed_daemon = is_installed_daemon(false); + let option_stop_service = "stop-service"; + let is_service_stopped = hbb_common::config::option2bool( + option_stop_service, + &crate::ui_interface::get_option(option_stop_service), + ); let app_name = crate::get_app_name(); if is_installed_daemon && !is_service_stopped { let agent = format!("{}_server.plist", crate::get_full_name()); let agent_plist_file = format!("/Library/LaunchAgents/{}", agent); - update_daemon_agent(agent_plist_file, app_dir, true); + update_daemon_agent(agent_plist_file, app_dir, true, keep_current_process)?; } else { // `kill -9` may not work without "administrator privileges" let update_body = r#" -on run {app_name, cur_pid, app_dir, user_name} - set app_bundle to "/Applications/" & app_name & ".app" - set app_bundle_q to quoted form of app_bundle - set app_dir_q to quoted form of app_dir - set user_name_q to quoted form of user_name + on run {app_name, cur_pid, app_dir, user_name, restore_owner} + set app_bundle to "/Applications/" & app_name & ".app" + set app_bundle_q to quoted form of app_bundle + set app_dir_q to quoted form of app_dir + set user_name_q to quoted form of user_name - set check_source to "test -d " & app_dir_q & " || exit 1;" - set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" - set copy_files to "rm -rf " & app_bundle_q & " && ditto " & app_dir_q & " " & app_bundle_q & " && chown -R " & user_name_q & ":staff " & app_bundle_q & " && (xattr -r -d com.apple.quarantine " & app_bundle_q & " || true);" - set sh to "set -e;" & check_source & kill_others & copy_files + set check_source to "test -d " & app_dir_q & " || exit 1;" + set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" + set trusted_signer to "trusted_signer() { codesign --verify --deep --strict \"$1\"; };" + set verify_source to "trusted_signer " & app_dir_q & ";" + set prepare_verified to "verified_dir=$(mktemp -d /tmp/.rustdeskupdate-verified.XXXXXX); verified_app=\"$verified_dir/" & app_name & ".app\"; ditto " & app_dir_q & " \"$verified_app\" && chown -R root:wheel \"$verified_app\" && chmod -R go-w \"$verified_app\" && trusted_signer \"$verified_app\";" + set prepare_swap_paths to "temp_bundle=" & app_bundle_q & ".new.$$; old_bundle=" & app_bundle_q & ".old.$$;" + set cleanup_swap_paths to "rm -rf \"$temp_bundle\" \"$old_bundle\";" + set stage_bundle to "ditto \"$verified_app\" \"$temp_bundle\";" + set protect_staged_bundle to "chown -R root:wheel \"$temp_bundle\"; chmod -R go-w \"$temp_bundle\"; (xattr -r -d com.apple.quarantine \"$temp_bundle\" || true); trusted_signer \"$temp_bundle\";" + set move_current_bundle to "if [ -e " & app_bundle_q & " ]; then mv " & app_bundle_q & " \"$old_bundle\"; fi;" + set install_staged_bundle to "if mv \"$temp_bundle\" " & app_bundle_q & "; then rm -rf \"$old_bundle\"; else if [ -e \"$old_bundle\" ]; then mv \"$old_bundle\" " & app_bundle_q & "; fi; exit 1; fi;" + set restore_installed_owner to "if [ " & quoted form of restore_owner & " = '1' ]; then chown -R " & user_name_q & ":staff " & app_bundle_q & "; fi; trusted_signer " & app_bundle_q & ";" + set copy_files to prepare_swap_paths & cleanup_swap_paths & stage_bundle & protect_staged_bundle & move_current_bundle & install_staged_bundle & restore_installed_owner + set cleanup_verified to "if [ -n \"${temp_bundle:-}\" ]; then rm -rf \"$temp_bundle\"; fi; if [ -n \"${verified_dir:-}\" ]; then rm -rf \"$verified_dir\"; fi;" + set sh to "set -e; trap " & quoted form of cleanup_verified & " EXIT;" & trusted_signer & check_source & verify_source & kill_others & prepare_verified & copy_files - do shell script sh with prompt app_name & " wants to update itself" with administrator privileges -end run - "#; - let active_user = get_active_username(); - let status = Command::new("osascript") + do shell script sh with prompt app_name & " wants to update itself" with administrator privileges + end run + "#; + let output = Command::new("osascript") .arg("-e") .arg(update_body) .arg(app_name.to_string()) - .arg(std::process::id().to_string()) + .arg(current_pid_for_update_script(keep_current_process)) .arg(app_dir) - .arg(active_user) - .status(); - match status { - Ok(status) if !status.success() => { - log::error!("osascript execution failed with status: {}", status); + .arg(get_active_username()) + .arg(if is_installed_daemon { "0" } else { "1" }) + .output(); + match output { + Ok(output) if !output.status.success() => { + log::error!("osascript execution failed with status: {}", output.status); + bail!("osascript execution failed with status: {}", output.status); } Err(e) => { log::error!("run osascript failed: {}", e); + bail!("run osascript failed: {}", e); } _ => {} } @@ -906,39 +1026,82 @@ pub fn update_from_dmg(dmg_path: &str) -> ResultType<()> { Ok(()) } -pub fn update_to(_file: &str) -> ResultType<()> { +pub fn update_to_verified_dmg( + file: &str, + expected_sha256: &str, + before_prompt: Option, +) -> ResultType<()> { let update_temp_dir = get_update_temp_dir_string(); - update_extracted(&update_temp_dir)?; + let update_res = update_from_verified_dmg(file, expected_sha256, before_prompt); + try_remove_temp_update_dir(Some(&update_temp_dir)); + update_res?; + quit_gui(); Ok(()) } -pub fn extract_update_dmg(file: &str) { - let update_temp_dir = get_update_temp_dir_string(); - let mut evt: HashMap<&str, String> = - HashMap::from([("name", "extract-update-dmg".to_string())]); - match extract_dmg(file, &update_temp_dir) { - Ok(_) => { - log::info!("Extracted dmg file to {}", update_temp_dir); - } - Err(e) => { - evt.insert("err", e.to_string()); - log::error!("Failed to extract dmg file {}: {}", file, e); - } - } - let evt = serde_json::ser::to_string(&evt).unwrap_or("".to_owned()); - #[cfg(feature = "flutter")] - crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, evt); +fn update_from_verified_dmg( + dmg_path: &str, + expected_sha256: &str, + before_prompt: Option, +) -> ResultType<()> { + let (mut dmg_file, temp_dmg_path) = copy_dmg_to_update_temp_file(dmg_path)?; + verify_dmg_file_sha256(&mut dmg_file, expected_sha256, dmg_path)?; + update_from_mounted_dmg(&temp_dmg_path.to_string_lossy(), before_prompt) } -fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let mount_point = "/Volumes/RustDeskUpdate"; - let target_path = Path::new(target_dir); +fn copy_dmg_to_update_temp_file(dmg_path: &str) -> ResultType<(std::fs::File, PathBuf)> { + let mut source_file = std::fs::File::open(dmg_path)?; + let (mut dmg_file, file_path) = create_update_temp_dmg_file()?; + std::io::copy(&mut source_file, &mut dmg_file)?; + dmg_file.flush()?; + dmg_file.seek(std::io::SeekFrom::Start(0))?; + Ok((dmg_file, file_path)) +} - if target_path.exists() { - std::fs::remove_dir_all(target_path)?; +fn verify_dmg_file_sha256( + dmg_file: &mut std::fs::File, + expected_sha256: &str, + display_path: &str, +) -> ResultType<()> { + let expected_sha256 = expected_sha256.trim(); + if expected_sha256.len() != 64 || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("Expected DMG SHA256 is malformed for {}", display_path); } - std::fs::create_dir_all(target_path)?; + dmg_file.seek(std::io::SeekFrom::Start(0))?; + let mut hasher = sha2::Sha256::default(); + let mut buffer = [0_u8; 8192]; + loop { + let count = dmg_file.read(&mut buffer)?; + if count == 0 { + break; + } + sha2::Digest::update(&mut hasher, &buffer[..count]); + } + + let actual_sha256 = format!("{:x}", sha2::Digest::finalize(hasher)); + if actual_sha256 != expected_sha256.to_ascii_lowercase() { + bail!( + "SHA256 mismatch for {}: expected {}, got {}", + display_path, + expected_sha256, + actual_sha256 + ); + } + Ok(()) +} + +struct DmgGuard(&'static str); + +impl Drop for DmgGuard { + fn drop(&mut self) { + let _ = Command::new("hdiutil") + .args(&["detach", self.0, "-force"]) + .status(); + } +} + +fn attach_dmg(dmg_path: &str, mount_point: &'static str) -> ResultType { let status = Command::new("hdiutil") .args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) .status()?; @@ -947,18 +1110,36 @@ fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); } - struct DmgGuard(&'static str); - impl Drop for DmgGuard { - fn drop(&mut self) { - let _ = Command::new("hdiutil") - .args(&["detach", self.0, "-force"]) - .status(); - } + Ok(DmgGuard(mount_point)) +} + +fn app_path_in_dmg_mount(mount_point: &str, app_name: &str) -> String { + format!("{}/{}.app", mount_point, app_name) +} + +fn update_from_mounted_dmg(dmg_path: &str, before_prompt: Option) -> ResultType<()> { + let _guard = attach_dmg(dmg_path, UPDATE_DMG_MOUNT_POINT)?; + if let Some(before_prompt) = before_prompt { + before_prompt(); } - let _guard = DmgGuard(mount_point); + update_me_from_app_dir( + app_path_in_dmg_mount(UPDATE_DMG_MOUNT_POINT, &crate::get_app_name()), + true, + ) +} + +fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let target_path = Path::new(target_dir); + + if target_path.exists() { + std::fs::remove_dir_all(target_path)?; + } + std::fs::create_dir_all(target_path)?; + + let _guard = attach_dmg(dmg_path, UPDATE_DMG_MOUNT_POINT)?; let app_name = format!("{}.app", crate::get_app_name()); - let src_path = format!("{}/{}", mount_point, app_name); + let src_path = format!("{}/{}", UPDATE_DMG_MOUNT_POINT, app_name); let dest_path = format!("{}/{}", target_dir, app_name); let copy_status = Command::new("ditto") @@ -985,27 +1166,14 @@ fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { } fn update_extracted(target_dir: &str) -> ResultType<()> { - let app_name = crate::get_app_name(); - let exe_path = format!( - "{}/{}.app/Contents/MacOS/{}", - target_dir, app_name, app_name - ); - let _child = unsafe { - if let Err(e) = Command::new(&exe_path) - .arg("--update") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .pre_exec(|| { - hbb_common::libc::setsid(); - Ok(()) - }) - .spawn() - { - try_remove_temp_update_dir(Some(target_dir)); - bail!(e); - } - }; + update_me_from_app_dir( + Path::new(target_dir) + .join(format!("{}.app", crate::get_app_name())) + .to_string_lossy() + .to_string(), + true, + )?; + try_remove_temp_update_dir(Some(target_dir)); Ok(()) } @@ -1020,6 +1188,129 @@ pub fn hide_dock() { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_temp_dir_is_process_specific() { + let euid = unsafe { hbb_common::libc::geteuid() }; + let old_fixed_dir = Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)); + let update_temp_dir = new_update_temp_dir(); + + assert_ne!(update_temp_dir, old_fixed_dir); + assert!(update_temp_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .starts_with(&format!(".rustdeskupdate-{}-", euid))); + } + + #[test] + fn test_remove_temp_update_dir_cleans_current_user_old_dirs() { + let euid = unsafe { hbb_common::libc::geteuid() }; + let stale_dir = Path::new("/tmp").join(format!( + ".rustdeskupdate-{}-cleanup-test-{}-{}", + euid, + std::process::id(), + hbb_common::rand::random::() + )); + let unrelated_dir = Path::new("/tmp").join(format!( + ".rustdeskupdate-cleanup-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::create_dir_all(&stale_dir).unwrap(); + std::fs::create_dir_all(&unrelated_dir).unwrap(); + + try_remove_temp_update_dir(None); + + assert!(!stale_dir.exists()); + assert!(unrelated_dir.exists()); + std::fs::remove_dir_all(&unrelated_dir).unwrap(); + } + + #[test] + fn test_remove_temp_update_dir_removes_symlink_without_touching_target() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-macos-cleanup-symlink-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let target_dir = test_dir.join("target"); + let link_path = test_dir.join("link"); + let target_file = target_dir.join("file"); + std::fs::create_dir_all(&target_dir).unwrap(); + std::fs::write(&target_file, b"target").unwrap(); + std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); + + remove_temp_update_dir(&link_path); + + assert!(!std::fs::symlink_metadata(&link_path).is_ok()); + assert!(target_file.exists()); + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn test_update_script_pid_controls_current_process_preservation() { + assert_eq!( + current_pid_for_update_script(true), + std::process::id().to_string() + ); + assert_eq!(current_pid_for_update_script(false), "0"); + } + + #[test] + fn test_verify_dmg_file_sha256_uses_open_file() { + let file_path = std::env::temp_dir().join(format!( + "rustdesk-macos-dmg-sha256-test-{}", + std::process::id() + )); + std::fs::write(&file_path, b"rustdesk").unwrap(); + let mut file = std::fs::File::open(&file_path).unwrap(); + + let result = verify_dmg_file_sha256( + &mut file, + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + &file_path.to_string_lossy(), + ); + + std::fs::remove_file(&file_path).unwrap(); + assert!(result.is_ok()); + } + + #[test] + fn test_update_to_verified_dmg_cleans_temp_dir_on_sha256_failure() { + let file_path = std::env::temp_dir().join(format!( + "rustdesk-macos-dmg-cleanup-test-{}", + std::process::id() + )); + std::fs::write(&file_path, b"rustdesk").unwrap(); + + let result = update_to_verified_dmg( + &file_path.to_string_lossy(), + "0000000000000000000000000000000000000000000000000000000000000000", + None, + ); + + std::fs::remove_file(&file_path).unwrap(); + assert!(result.is_err()); + assert!(!get_update_temp_dir().exists()); + } + + #[test] + fn test_create_update_temp_dmg_file_keeps_named_file() { + let (_file, file_path) = create_update_temp_dmg_file().unwrap(); + let dmg_dir = file_path.parent().unwrap(); + let mode = std::fs::metadata(dmg_dir).unwrap().permissions().mode() & 0o777; + + assert!(file_path.exists()); + assert_eq!(dmg_dir.parent(), Some(get_update_temp_dir().as_path())); + assert_eq!(mode, 0o700); + std::fs::remove_file(file_path).unwrap(); + } +} + #[inline] #[allow(dead_code)] fn get_server_start_time_of(p: &Process, path: &Path) -> Option { diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt index 0484c257a..c3d9ddd9e 100644 --- a/src/platform/privileges_scripts/update.scpt +++ b/src/platform/privileges_scripts/update.scpt @@ -5,12 +5,22 @@ on run {daemon_file, agent_file, user, cur_pid, source_dir} set app_bundle to "/Applications/RustDesk.app" set check_source to "test -d " & quoted form of source_dir & " || exit 1;" + set trusted_signer to "trusted_signer() { codesign --verify --deep --strict \"$1\"; };" + set verify_source to "trusted_signer " & quoted form of source_dir & ";" + set prepare_verified to "verified_dir=$(mktemp -d /tmp/.rustdeskupdate-verified.XXXXXX); verified_app=\"$verified_dir/RustDesk.app\"; ditto " & quoted form of source_dir & " \"$verified_app\" && chown -R root:wheel \"$verified_app\" && chmod -R go-w \"$verified_app\" && trusted_signer \"$verified_app\";" set resolve_uid to "uid=$(id -u " & quoted form of user & " 2>/dev/null || true);" set unload_agent to "if [ -n \"$uid\" ]; then launchctl bootout gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootout user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl unload -w " & quoted form of agent_plist & " || true; else launchctl unload -w " & quoted form of agent_plist & " || true; fi;" set unload_service to "launchctl unload -w " & daemon_plist & " || true;" set kill_others to "pids=$(pgrep -x 'RustDesk' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" - set copy_files to "(rm -rf " & quoted form of app_bundle & " && ditto " & quoted form of source_dir & " " & quoted form of app_bundle & " && chown -R " & quoted form of user & ":staff " & quoted form of app_bundle & " && (xattr -r -d com.apple.quarantine " & quoted form of app_bundle & " || true)) || exit 1;" + set prepare_swap_paths to "temp_bundle=" & quoted form of app_bundle & ".new.$$; old_bundle=" & quoted form of app_bundle & ".old.$$;" + set cleanup_swap_paths to "rm -rf \"$temp_bundle\" \"$old_bundle\";" + set stage_bundle to "ditto \"$verified_app\" \"$temp_bundle\";" + set protect_staged_bundle to "chown -R root:wheel \"$temp_bundle\"; chmod -R go-w \"$temp_bundle\"; (xattr -r -d com.apple.quarantine \"$temp_bundle\" || true); trusted_signer \"$temp_bundle\";" + set move_current_bundle to "if [ -e " & quoted form of app_bundle & " ]; then mv " & quoted form of app_bundle & " \"$old_bundle\"; fi;" + set install_staged_bundle to "if mv \"$temp_bundle\" " & quoted form of app_bundle & "; then rm -rf \"$old_bundle\"; else if [ -e \"$old_bundle\" ]; then mv \"$old_bundle\" " & quoted form of app_bundle & "; fi; exit 1; fi;" + set copy_files to prepare_swap_paths & cleanup_swap_paths & stage_bundle & protect_staged_bundle & move_current_bundle & install_staged_bundle + set cleanup_verified to "if [ -n \"${temp_bundle:-}\" ]; then rm -rf \"$temp_bundle\"; fi; if [ -n \"${verified_dir:-}\" ]; then rm -rf \"$verified_dir\"; fi;" set write_daemon_plist to "echo " & quoted form of daemon_file & " > " & daemon_plist & " && chown root:wheel " & daemon_plist & ";" set write_agent_plist to "echo " & quoted form of agent_file & " > " & agent_plist & " && chown root:wheel " & agent_plist & ";" @@ -20,7 +30,7 @@ on run {daemon_file, agent_file, user, cur_pid, source_dir} set kickstart_agent to "if [ -n \"$uid\" ]; then launchctl kickstart -k gui/$uid/$agent_label 2>/dev/null || launchctl kickstart -k user/$uid/$agent_label 2>/dev/null || true; fi;" set load_agent to agent_label_cmd & bootstrap_agent & kickstart_agent - set sh to "set -e;" & check_source & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent + set sh to "set -e; trap " & quoted form of cleanup_verified & " EXIT;" & trusted_signer & check_source & verify_source & prepare_verified & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges end run diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 4c09bbe9f..ccfab5675 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -17,6 +17,7 @@ use hbb_common::{ sysinfo::{Pid, System}, timeout, tokio, }; +use sha2::Digest; use std::{ collections::HashMap, ffi::{CString, OsString}, @@ -25,7 +26,11 @@ use std::{ mem, os::{ raw::c_ulong, - windows::{ffi::OsStringExt, process::CommandExt}, + windows::{ + ffi::OsStringExt, + fs::{MetadataExt, OpenOptionsExt}, + process::CommandExt, + }, }, path::*, ptr::null_mut, @@ -53,14 +58,15 @@ use winapi::{ AllocateAndInitializeSid, DuplicateToken, EqualSid, FreeSid, GetTokenInformation, }, shellapi::ShellExecuteW, - sysinfoapi::{GetNativeSystemInfo, SYSTEM_INFO}, + sysinfoapi::{GetNativeSystemInfo, GetSystemDirectoryW, SYSTEM_INFO}, winbase::*, wingdi::*, winnt::{ SecurityImpersonation, TokenElevation, TokenGroups, TokenImpersonation, TokenType, DOMAIN_ALIAS_RID_ADMINS, ES_AWAYMODE_REQUIRED, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, - ES_SYSTEM_REQUIRED, HANDLE, PROCESS_ALL_ACCESS, PROCESS_QUERY_LIMITED_INFORMATION, - PSID, SECURITY_BUILTIN_DOMAIN_RID, SECURITY_NT_AUTHORITY, SID_IDENTIFIER_AUTHORITY, + ES_SYSTEM_REQUIRED, FILE_ATTRIBUTE_REPARSE_POINT, FILE_SHARE_READ, HANDLE, + PROCESS_ALL_ACCESS, PROCESS_QUERY_LIMITED_INFORMATION, PSID, + SECURITY_BUILTIN_DOMAIN_RID, SECURITY_NT_AUTHORITY, SID_IDENTIFIER_AUTHORITY, TOKEN_ELEVATION, TOKEN_GROUPS, TOKEN_QUERY, TOKEN_TYPE, }, winreg::HKEY_CURRENT_USER, @@ -3384,33 +3390,290 @@ pub fn handle_custom_client_staging_dir_before_update( Ok(()) } -// Used for auto update and manual update in the main window. -pub fn update_to(file: &str) -> ResultType<()> { - if file.ends_with(".exe") { - let custom_client_staging_dir = get_custom_client_staging_dir(); - if crate::is_custom_client() { - handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; - } else { - // Clean up any residual staging directory from previous custom client - allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); +pub fn update_to_verified(file: &str, expected_sha256: &str) -> ResultType<()> { + match update_file_extension(file).as_deref() { + Some("exe") => { + let update_file = verify_update_file_signature_and_sha256(file, expected_sha256)?; + let custom_client_staging_dir = get_custom_client_staging_dir(); + if crate::is_custom_client() { + handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; + } else { + // Clean up any residual staging directory from previous custom client + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + } + if !run_uac(update_file.path_str()?, "--update")? { + bail!( + "Failed to run the update exe with UAC, error: {:?}", + std::io::Error::last_os_error() + ); + } } - if !run_uac(file, "--update")? { - bail!( - "Failed to run the update exe with UAC, error: {:?}", - std::io::Error::last_os_error() - ); + Some("msi") => { + let update_file = verify_update_file_signature_and_sha256(file, expected_sha256)?; + if let Err(e) = update_me_msi(update_file.path_str()?, false) { + bail!("Failed to run the update msi: {}", e); + } } - } else if file.ends_with(".msi") { - if let Err(e) = update_me_msi(file, false) { - bail!("Failed to run the update msi: {}", e); + _ => { + // unreachable!() + bail!("Unsupported update file format: {}", file); } - } else { - // unreachable!() - bail!("Unsupported update file format: {}", file); } Ok(()) } +const UPDATE_FILE_ENV: &str = "RUSTDESK_UPDATE_FILE"; + +const AUTHENTICODE_VERIFICATION_SCRIPT: &str = r#" +$ErrorActionPreference = 'Stop' +$updateFile = $env:RUSTDESK_UPDATE_FILE +if ([string]::IsNullOrWhiteSpace($updateFile)) { + Write-Error 'Missing update file path' + exit 10 +} +$candidate = Get-AuthenticodeSignature -LiteralPath $updateFile +if ($candidate.Status -ne 'Valid') { + Write-Error ('Update file signature status: ' + $candidate.Status) + exit 11 +} +if ($null -eq $candidate.SignerCertificate) { + Write-Error 'Missing signer certificate' + exit 12 +} +$publisherName = $candidate.SignerCertificate.GetNameInfo( + [System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, + $false +) +if ([string]::IsNullOrWhiteSpace($publisherName)) { + Write-Error 'Missing signer publisher name' + exit 13 +} +Write-Output $publisherName +exit 0 +"#; + +const UPDATE_FILE_COPY_ATTEMPTS: usize = 16; + +pub struct VerifiedUpdateFile { + file: std::fs::File, + path: PathBuf, +} + +impl VerifiedUpdateFile { + pub fn path_str(&self) -> ResultType<&str> { + let Some(path) = self.path.to_str() else { + bail!("Invalid update file path: {}", self.path.display()); + }; + Ok(path) + } +} + +fn is_trusted_update_publisher_name(publisher_name: &str) -> bool { + let publisher_name = publisher_name.trim().to_ascii_lowercase(); + publisher_name.contains("purslane") || publisher_name.contains("rustdesk") +} + +fn is_update_file_attributes_trusted(attributes: u32) -> bool { + attributes & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +fn update_file_open_flags() -> u32 { + FILE_FLAG_OPEN_REPARSE_POINT +} + +fn update_file_extension(file: &str) -> Option { + Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) +} + +fn verified_update_file_path(file: &str) -> ResultType { + let path = Path::new(file); + let extension = update_file_extension(file).unwrap_or_default(); + if extension != "exe" && extension != "msi" { + bail!("Unsupported update file format: {}", file); + } + Ok(std::env::temp_dir().join(format!( + "rustdesk-verified-{}-{}.{}", + std::process::id(), + hbb_common::rand::random::(), + extension + ))) +} + +fn is_temp_update_file_name(file_name: &str) -> bool { + if file_name.starts_with(".rustdesk-") && file_name.ends_with(".download") { + return file_name.contains(".exe.") || file_name.contains(".msi."); + } + if !(file_name.ends_with(".msi") || file_name.ends_with(".exe")) { + return false; + } + file_name.starts_with("rustdesk-") +} + +fn open_update_file_for_verification(file: &str) -> ResultType { + let update_file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(update_file_open_flags()) + .open(file) + .map_err(|e| anyhow!("Failed to lock update file {}: {}", file, e))?; + let metadata = update_file + .metadata() + .map_err(|e| anyhow!("Failed to read update file metadata {}: {}", file, e))?; + if !is_update_file_attributes_trusted(metadata.file_attributes()) { + bail!( + "Refusing to verify update file through reparse point: {}", + file + ); + } + Ok(update_file) +} + +fn copy_update_file_for_verification(file: &str) -> ResultType { + let mut source_file = open_update_file_for_verification(file)?; + for _ in 0..UPDATE_FILE_COPY_ATTEMPTS { + let path = verified_update_file_path(file)?; + let mut copy_file = match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(update_file_open_flags()) + .open(&path) + { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(anyhow!( + "Failed to create verified update file {}: {}", + path.display(), + e + ) + .into()) + } + }; + if let Err(e) = std::io::copy(&mut source_file, &mut copy_file) { + std::fs::remove_file(&path).ok(); + return Err(e.into()); + } + if let Err(e) = copy_file.flush() { + std::fs::remove_file(&path).ok(); + return Err(e.into()); + } + drop(copy_file); + let path_string = path.to_string_lossy().to_string(); + let update_file = match open_update_file_for_verification(&path_string) { + Ok(file) => file, + Err(e) => { + std::fs::remove_file(&path).ok(); + return Err(e); + } + }; + return Ok(VerifiedUpdateFile { + file: update_file, + path, + }); + } + + bail!("Failed to create verified update file for {}", file); +} + +pub fn verify_update_file_signature_and_sha256( + file: &str, + expected_sha256: &str, +) -> ResultType { + let mut update_file = copy_update_file_for_verification(file)?; + let update_path = match update_file.path_str() { + Ok(path) => path.to_owned(), + Err(e) => { + std::fs::remove_file(&update_file.path).ok(); + return Err(e); + } + }; + if let Err(e) = verify_update_file_sha256(&mut update_file.file, expected_sha256, &update_path) + .and_then(|_| verify_update_file_signature_for_path(&update_path)) + { + std::fs::remove_file(&update_file.path).ok(); + return Err(e); + } + Ok(update_file) +} + +fn verify_update_file_sha256( + update_file: &mut std::fs::File, + expected_sha256: &str, + file: &str, +) -> ResultType<()> { + let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); + if expected_sha256.len() != 64 || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("Expected update file SHA256 is malformed for {}", file); + } + + update_file.seek(io::SeekFrom::Start(0))?; + let mut hasher = sha2::Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let count = update_file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + update_file.seek(io::SeekFrom::Start(0))?; + + let actual_sha256 = format!("{:x}", hasher.finalize()); + if actual_sha256 != expected_sha256 { + bail!( + "SHA256 mismatch for {}: expected {}, got {}", + file, + expected_sha256, + actual_sha256 + ); + } + Ok(()) +} + +fn verify_update_file_signature_for_path(file: &str) -> ResultType<()> { + let mut system_dir = [0u16; MAX_PATH]; + let len = unsafe { GetSystemDirectoryW(system_dir.as_mut_ptr(), system_dir.len() as u32) }; + if len == 0 || len as usize >= system_dir.len() { + bail!("Failed to get Windows system directory"); + } + let powershell = PathBuf::from(OsString::from_wide(&system_dir[..len as usize])) + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + if !powershell.exists() { + bail!("PowerShell not found at {}", powershell.display()); + } + let output = std::process::Command::new(&powershell) + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(AUTHENTICODE_VERIFICATION_SCRIPT) + .env(UPDATE_FILE_ENV, file) + .output()?; + if output.status.success() { + let publisher_name = String::from_utf8_lossy(&output.stdout); + let publisher_name = publisher_name.trim(); + if is_trusted_update_publisher_name(publisher_name) { + return Ok(()); + } + bail!( + "Update file signer publisher is not trusted for {}: {}", + file, + publisher_name + ); + } + + bail!( + "Update file signature verification failed for {}: {}{}", + file, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); +} + // Don't launch tray app when running with `\qn`. // 1. Because `/qn` requires administrator permission and the tray app should be launched with user permission. // Or launching the main window from the tray app will cause the main window to be launched with administrator permission. @@ -3423,7 +3686,7 @@ pub fn update_to(file: &str) -> ResultType<()> { // We need also to handle the command line parsing to find the tray processes. pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { let cmds = format!( - "chcp 65001 && msiexec /i {msi} {}", + "chcp 65001 && msiexec /i \"{msi}\" {}", if quiet { "/qn LAUNCH_TRAY_APP=N" } else { "" } ); run_cmds(cmds, false, "update-msi")?; @@ -3522,10 +3785,8 @@ pub fn try_remove_temp_update_files() { if let Ok(entry) = entry { let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - // Match files like rustdesk-*.msi or rustdesk-*.exe - if file_name.starts_with("rustdesk-") - && (file_name.ends_with(".msi") || file_name.ends_with(".exe")) - { + // Match cached update files, verified copies, and stale download temp files. + if is_temp_update_file_name(file_name) { // Skip files modified within the last hour to avoid deleting files being downloaded if let Ok(metadata) = std::fs::metadata(&path) { if let Ok(modified) = metadata.modified() { @@ -4281,6 +4542,138 @@ pub(super) fn get_pids_with_first_arg_by_wmic, S2: AsRef>( #[cfg(test)] mod tests { use super::*; + + #[test] + fn update_file_attributes_reject_reparse_points() { + assert!(!is_update_file_attributes_trusted( + winapi::um::winnt::FILE_ATTRIBUTE_REPARSE_POINT + )); + assert!(is_update_file_attributes_trusted( + winapi::um::winnt::FILE_ATTRIBUTE_ARCHIVE + )); + } + + #[test] + fn update_file_open_flags_do_not_follow_reparse_points() { + assert_ne!( + update_file_open_flags() & winapi::um::winbase::FILE_FLAG_OPEN_REPARSE_POINT, + 0 + ); + } + + #[test] + fn authenticode_script_reads_update_file_from_environment() { + assert!(AUTHENTICODE_VERIFICATION_SCRIPT.contains("$env:RUSTDESK_UPDATE_FILE")); + assert!(!AUTHENTICODE_VERIFICATION_SCRIPT.contains("$args[0]")); + } + + #[test] + fn verified_update_file_path_preserves_update_extension() { + let path = verified_update_file_path(r"C:\Temp\rustdesk-1.4.6-x86_64.exe").unwrap(); + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap(); + + assert!(file_name.starts_with("rustdesk-verified-")); + assert!(file_name.ends_with(".exe")); + } + + #[test] + fn verified_update_file_path_accepts_uppercase_update_extension() { + let path = verified_update_file_path(r"C:\Temp\rustdesk-1.4.6-x86_64.EXE").unwrap(); + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap(); + + assert!(file_name.starts_with("rustdesk-verified-")); + assert!(file_name.ends_with(".exe")); + } + + #[test] + fn verified_update_file_path_rejects_unsupported_extension() { + assert!(verified_update_file_path(r"C:\Temp\rustdesk-1.4.6.zip").is_err()); + } + + #[test] + fn temp_update_file_name_accepts_verified_update_copy() { + assert!(is_temp_update_file_name("rustdesk-verified-123-456.exe")); + assert!(is_temp_update_file_name("rustdesk-verified-123-456.msi")); + assert!(is_temp_update_file_name( + ".rustdesk-1.4.6-x86_64.exe.123.456.download" + )); + assert!(is_temp_update_file_name( + ".rustdesk-1.4.6-x86_64.msi.123.456.download" + )); + } + + #[test] + fn temp_update_file_name_rejects_unrelated_file() { + assert!(!is_temp_update_file_name("verified-not-an-update.exe")); + assert!(!is_temp_update_file_name("other-verified-alpha-beta.exe")); + assert!(!is_temp_update_file_name("rustdesk-1.4.6.zip")); + assert!(!is_temp_update_file_name( + ".rustdesk-1.4.6-x86_64.zip.123.456.download" + )); + } + + #[test] + fn update_file_sha256_matches_open_file_handle() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-sha256-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + + let update_file_path = test_dir.join("update.exe"); + std::fs::write(&update_file_path, b"rustdesk").unwrap(); + let update_file_path = update_file_path.to_string_lossy().to_string(); + let mut update_file = open_update_file_for_verification(&update_file_path).unwrap(); + + let result = verify_update_file_sha256( + &mut update_file, + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + &update_file_path, + ); + + let _ = std::fs::remove_dir_all(&test_dir); + assert!(result.is_ok()); + } + + #[test] + fn update_file_sha256_rejects_mismatched_open_file_handle() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-sha256-mismatch-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + + let update_file_path = test_dir.join("update.exe"); + std::fs::write(&update_file_path, b"rustdesk").unwrap(); + let update_file_path = update_file_path.to_string_lossy().to_string(); + let mut update_file = open_update_file_for_verification(&update_file_path).unwrap(); + + let result = verify_update_file_sha256( + &mut update_file, + "0000000000000000000000000000000000000000000000000000000000000000", + &update_file_path, + ); + + let _ = std::fs::remove_dir_all(&test_dir); + assert!(result.is_err()); + } + + #[test] + fn accepts_trusted_update_publisher_names() { + assert!(is_trusted_update_publisher_name("RustDesk LLC")); + assert!(is_trusted_update_publisher_name("Purslane")); + assert!(is_trusted_update_publisher_name("PURSLANE")); + assert!(is_trusted_update_publisher_name("rustdesk llc")); + } + + #[test] + fn rejects_untrusted_update_publisher_names() { + assert!(!is_trusted_update_publisher_name("Untrusted Publisher")); + assert!(!is_trusted_update_publisher_name("")); + } + #[test] fn test_uninstall_cert() { println!("uninstall driver certs: {:?}", cert::uninstall_cert()); diff --git a/src/updater.rs b/src/updater.rs index 357f111a7..6f1e67aaa 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,8 +1,8 @@ use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url}; use hbb_common::{bail, config, log, ResultType}; use std::{ - io::Write, - path::PathBuf, + io::{Read, Write}, + path::{Path, PathBuf}, sync::{ atomic::{AtomicUsize, Ordering}, mpsc::{channel, Receiver, Sender}, @@ -146,10 +146,11 @@ fn check_update(manually: bool) -> ResultType<()> { format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) }; log::debug!("New version available: {}", &version); - let client = create_http_client_with_url(&download_url); + let client = create_http_client_with_url(&download_url, Some(false)); let Some(file_path) = get_download_file_from_url(&download_url) else { bail!("Failed to get the file path from the URL: {}", download_url); }; + let expected_sha256 = fetch_github_asset_sha256(&update_url, &download_url)?; let mut is_file_exists = false; if file_path.exists() { // Check if the file size is the same as the server file size @@ -168,7 +169,13 @@ fn check_update(manually: bool) -> ResultType<()> { bail!("Failed to get content length"); }; if file_size == total_size { - is_file_exists = true; + match verify_file_sha256(&file_path, &expected_sha256) { + Ok(()) => is_file_exists = true, + Err(e) => { + log::warn!("Removing cached update file with invalid SHA256: {}", e); + std::fs::remove_file(&file_path)?; + } + } } else { std::fs::remove_file(&file_path)?; } @@ -182,22 +189,21 @@ fn check_update(manually: bool) -> ResultType<()> { ); } let file_data = response.bytes()?; - let mut file = std::fs::File::create(&file_path)?; - file.write_all(&file_data)?; + write_verified_download(&file_path, &file_data, &expected_sha256)?; } // We have checked if the `conns` is empty before, but we need to check again. // No need to care about the downloaded file here, because it's rare case that the `conns` are empty // before the download, but not empty after the download. if has_no_active_conns() { #[cfg(target_os = "windows")] - update_new_version(update_msi, &version, &file_path); + update_new_version(update_msi, &version, &file_path, &expected_sha256); } } Ok(()) } #[cfg(target_os = "windows")] -fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { +fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf, expected_sha256: &str) { log::debug!( "New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}", file_path.to_str() @@ -205,7 +211,26 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { if let Some(p) = file_path.to_str() { if let Some(session_id) = crate::platform::get_current_process_session_id() { if update_msi { - match crate::platform::update_me_msi(p, true) { + let update_file = match crate::platform::verify_update_file_signature_and_sha256( + p, + expected_sha256, + ) { + Ok(update_file) => update_file, + Err(e) => { + log::error!("Refusing to update from untrusted msi: {}", e); + std::fs::remove_file(&file_path).ok(); + return; + } + }; + let update_path = match update_file.path_str() { + Ok(path) => path, + Err(e) => { + log::error!("Failed to get verified msi path: {}", e); + std::fs::remove_file(&file_path).ok(); + return; + } + }; + match crate::platform::update_me_msi(update_path, true) { Ok(_) => { log::debug!("New version \"{}\" updated.", version); } @@ -219,6 +244,25 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { } } } else { + let update_file = match crate::platform::verify_update_file_signature_and_sha256( + p, + expected_sha256, + ) { + Ok(update_file) => update_file, + Err(e) => { + log::error!("Refusing to update from untrusted exe: {}", e); + std::fs::remove_file(&file_path).ok(); + return; + } + }; + let update_path = match update_file.path_str() { + Ok(path) => path, + Err(e) => { + log::error!("Failed to get verified exe path: {}", e); + std::fs::remove_file(&file_path).ok(); + return; + } + }; let custom_client_staging_dir = if crate::is_custom_client() { let custom_client_staging_dir = crate::platform::get_custom_client_staging_dir(); @@ -243,7 +287,7 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { }; let update_launched = match crate::platform::launch_privileged_process( session_id, - &format!("{} --update", p), + &format!("\"{}\" --update", update_path), ) { Ok(h) => { if h.is_null() { @@ -288,3 +332,419 @@ pub fn get_download_file_from_url(url: &str) -> Option { let filename = url.split('/').last()?; Some(std::env::temp_dir().join(filename)) } + +fn create_download_temp_file(final_path: &Path) -> ResultType<(std::fs::File, PathBuf)> { + let Some(download_dir) = final_path.parent() else { + bail!( + "Update file has no parent directory: {}", + final_path.display() + ); + }; + let Some(file_name) = final_path.file_name() else { + bail!("Update file has no file name: {}", final_path.display()); + }; + let file_name = file_name.to_string_lossy(); + for _ in 0..16 { + let temp_path = download_dir.join(format!( + ".{}.{}.{}.download", + file_name, + std::process::id(), + hbb_common::rand::random::() + )); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + { + Ok(file) => return Ok((file, temp_path)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + bail!("Failed to create temporary update file"); +} + +fn install_verified_download(temp_path: &Path, final_path: &Path) -> ResultType<()> { + if std::fs::symlink_metadata(final_path).is_ok() { + std::fs::remove_file(final_path)?; + } + if let Err(e) = std::fs::rename(temp_path, final_path) { + std::fs::remove_file(temp_path).ok(); + return Err(e.into()); + } + Ok(()) +} + +fn write_and_verify_download_file( + file: &mut std::fs::File, + temp_path: &Path, + file_data: &[u8], + expected_sha256: &str, +) -> ResultType<()> { + file.write_all(file_data)?; + file.flush()?; + verify_file_sha256(temp_path, expected_sha256) +} + +fn write_verified_download( + final_path: &Path, + file_data: &[u8], + expected_sha256: &str, +) -> ResultType<()> { + let (mut file, temp_path) = create_download_temp_file(final_path)?; + if let Err(e) = + write_and_verify_download_file(&mut file, &temp_path, file_data, expected_sha256) + { + std::fs::remove_file(temp_path).ok(); + return Err(e); + } + if let Err(e) = install_verified_download(&temp_path, final_path) { + std::fs::remove_file(temp_path).ok(); + return Err(e); + } + Ok(()) +} + +#[derive(serde::Deserialize)] +struct GithubRelease { + assets: Vec, +} + +#[derive(serde::Deserialize)] +struct GithubReleaseAsset { + name: String, + digest: Option, +} + +fn fetch_github_asset_sha256(update_url: &str, download_url: &str) -> ResultType { + let api_url = github_release_api_url(update_url)?; + let asset_name = download_asset_name(download_url)?; + let metadata = fetch_github_release_metadata(&api_url)?; + github_release_asset_sha256(&metadata, &asset_name) +} + +fn fetch_github_release_metadata(api_url: &str) -> ResultType { + let client = create_http_client_with_url(&api_url, Some(false)); + let response = client + .get(api_url) + .header(reqwest::header::USER_AGENT, "rustdesk-updater") + .send()?; + if !response.status().is_success() { + bail!( + "Failed to get GitHub release metadata: {}", + response.status() + ); + } + Ok(response.text()?) +} + +pub fn download_file_expected_sha256(download_url: &str) -> ResultType { + fetch_github_asset_sha256(download_url, download_url) +} + +fn github_release_api_url(update_url: &str) -> ResultType { + let url = reqwest::Url::parse(update_url)?; + if url.scheme() != "https" || url.host_str() != Some("github.com") { + bail!( + "Update URL is not a GitHub HTTPS release URL: {}", + update_url + ); + } + + let Some(mut segments) = url.path_segments() else { + bail!("GitHub update URL has no path: {}", update_url); + }; + let Some(owner) = segments.next() else { + bail!("GitHub update URL has no owner: {}", update_url); + }; + let Some(repo) = segments.next() else { + bail!("GitHub update URL has no repo: {}", update_url); + }; + if owner != "rustdesk" || repo != "rustdesk" { + bail!( + "GitHub update URL is not a RustDesk release URL: {}", + update_url + ); + } + if segments.next() != Some("releases") { + bail!("GitHub update URL is not a release URL: {}", update_url); + } + + let tag = match segments.next() { + Some("tag") => segments.collect::>().join("/"), + Some("download") => { + let Some(tag) = segments.next() else { + bail!("GitHub update URL has no release tag: {}", update_url); + }; + if segments.next().is_none() { + bail!("GitHub update URL has no release asset: {}", update_url); + } + tag.to_owned() + } + _ => bail!( + "GitHub update URL is not a release tag or download URL: {}", + update_url + ), + }; + if tag.is_empty() { + bail!("GitHub update URL has no release tag: {}", update_url); + } + + Ok(format!( + "https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}" + )) +} + +fn download_asset_name(download_url: &str) -> ResultType { + let Some(asset_name) = download_url.split('/').last() else { + bail!("Download URL has no asset name: {}", download_url); + }; + if asset_name.is_empty() { + bail!("Download URL has empty asset name: {}", download_url); + } + Ok(asset_name.to_owned()) +} + +fn github_release_asset_sha256(release_json: &str, asset_name: &str) -> ResultType { + let release: GithubRelease = serde_json::from_str(release_json)?; + let Some(asset) = release.assets.iter().find(|asset| asset.name == asset_name) else { + bail!("GitHub release asset not found: {}", asset_name); + }; + let Some(digest) = asset.digest.as_deref() else { + bail!("GitHub release asset has no digest: {}", asset_name); + }; + parse_sha256_digest(digest) +} + +fn parse_sha256_digest(digest: &str) -> ResultType { + let Some(hex) = digest.strip_prefix("sha256:") else { + bail!("GitHub release asset digest is not SHA256: {}", digest); + }; + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + bail!( + "GitHub release asset SHA256 digest is malformed: {}", + digest + ); + } + Ok(hex.to_lowercase()) +} + +fn verify_file_sha256(path: &Path, expected_sha256: &str) -> ResultType<()> { + let actual_sha256 = sha256_file_hex(path)?; + if actual_sha256 != expected_sha256 { + bail!( + "SHA256 mismatch for {}: expected {}, got {}", + path.display(), + expected_sha256, + actual_sha256 + ); + } + Ok(()) +} + +fn sha256_file_hex(path: &Path) -> ResultType { + let mut file = std::fs::File::open(path)?; + let mut hasher = sha2::Sha256::default(); + let mut buffer = [0_u8; 8192]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + sha2::Digest::update(&mut hasher, &buffer[..count]); + } + Ok(format!("{:x}", sha2::Digest::finalize(hasher))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_release_api_url_accepts_tag_url() { + let api_url = + github_release_api_url("https://github.com/rustdesk/rustdesk/releases/tag/1.4.3") + .unwrap(); + + assert_eq!( + api_url, + "https://api.github.com/repos/rustdesk/rustdesk/releases/tags/1.4.3" + ); + } + + #[test] + fn github_release_api_url_accepts_download_url() { + let api_url = github_release_api_url( + "https://github.com/rustdesk/rustdesk/releases/download/1.4.3/rustdesk-1.4.3-x86_64.exe", + ) + .unwrap(); + + assert_eq!( + api_url, + "https://api.github.com/repos/rustdesk/rustdesk/releases/tags/1.4.3" + ); + } + + #[test] + fn github_release_api_url_rejects_non_release_download_url() { + assert!(github_release_api_url( + "https://github.com/rustdesk/rustdesk/archive/refs/tags/1.4.3.zip" + ) + .is_err()); + } + + #[test] + fn github_release_api_url_rejects_non_github_url() { + assert!(github_release_api_url("https://example.com/rustdesk/releases/tag/1.4.3").is_err()); + } + + #[test] + fn github_release_api_url_rejects_non_rustdesk_repo() { + assert!( + github_release_api_url("https://github.com/other/rustdesk/releases/tag/1.4.3").is_err() + ); + assert!( + github_release_api_url("https://github.com/rustdesk/other/releases/tag/1.4.3").is_err() + ); + } + + #[test] + fn github_release_digest_requires_exact_asset_name_and_sha256_digest() { + let json = r#"{ + "assets": [ + {"name": "rustdesk-1.4.3-x86_64.exe", "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + {"name": "rustdesk-1.4.3-x86_64.msi", "digest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"} + ] + }"#; + + let digest = github_release_asset_sha256(json, "rustdesk-1.4.3-x86_64.exe").unwrap(); + + assert_eq!( + digest, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + } + + #[test] + fn github_release_digest_rejects_missing_or_malformed_digest() { + let missing = r#"{"assets": [{"name": "rustdesk.exe"}]}"#; + let malformed = r#"{"assets": [{"name": "rustdesk.exe", "digest": "sha1:abcd"}]}"#; + + assert!(github_release_asset_sha256(missing, "rustdesk.exe").is_err()); + assert!(github_release_asset_sha256(malformed, "rustdesk.exe").is_err()); + } + + #[test] + fn verify_file_sha256_rejects_mismatched_file() { + let file_path = std::env::temp_dir().join(format!( + "rustdesk-updater-sha256-test-{}", + std::process::id() + )); + std::fs::write(&file_path, b"rustdesk").unwrap(); + + let result = verify_file_sha256( + &file_path, + "0000000000000000000000000000000000000000000000000000000000000000", + ); + std::fs::remove_file(&file_path).unwrap(); + + assert!(result.is_err()); + } + + #[test] + fn create_download_temp_file_uses_random_sibling_path() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-temp-file-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + + let (file, temp_path) = create_download_temp_file(&final_path).unwrap(); + + drop(file); + let temp_file_name = temp_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap(); + assert_ne!(temp_path, final_path); + assert_eq!(temp_path.parent(), Some(test_dir.as_path())); + assert!(temp_file_name.starts_with(".rustdesk-update.exe.")); + assert!(temp_file_name.ends_with(".download")); + assert!(temp_path.exists()); + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn write_verified_download_removes_temp_file_on_sha256_error() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-cleanup-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + + let result = write_verified_download( + &final_path, + b"update", + "0000000000000000000000000000000000000000000000000000000000000000", + ); + + assert!(result.is_err()); + assert!(!final_path.exists()); + assert!(std::fs::read_dir(&test_dir).unwrap().next().is_none()); + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[test] + fn write_verified_download_removes_temp_file_on_install_error() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-install-error-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + std::fs::create_dir(&final_path).unwrap(); + + let result = write_verified_download( + &final_path, + b"update", + "2937013f2181810606b2a799b05bda2849f3e369a20982a4138f0e0a55984ce4", + ); + + assert!(result.is_err()); + assert!(final_path.is_dir()); + assert_eq!(std::fs::read_dir(&test_dir).unwrap().count(), 1); + std::fs::remove_dir_all(&test_dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn install_verified_download_replaces_symlink_without_touching_target() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-symlink-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + let temp_path = test_dir.join(".rustdesk-update.exe.tmp"); + let victim_path = test_dir.join("victim"); + std::fs::write(&victim_path, b"victim").unwrap(); + std::os::unix::fs::symlink(&victim_path, &final_path).unwrap(); + std::fs::write(&temp_path, b"update").unwrap(); + + install_verified_download(&temp_path, &final_path).unwrap(); + + assert_eq!(std::fs::read(&victim_path).unwrap(), b"victim"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"update"); + assert!(!std::fs::symlink_metadata(&final_path) + .unwrap() + .file_type() + .is_symlink()); + std::fs::remove_dir_all(&test_dir).unwrap(); + } +} From ecfd1f8ceece57ae9b46fec17dc70eb7422b9b57 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 14:59:10 +0800 Subject: [PATCH 02/17] fix(update): refactor logs Signed-off-by: fufesou --- src/platform/macos.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 71696dde2..7bc7233ab 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1101,13 +1101,26 @@ impl Drop for DmgGuard { } } +fn attach_dmg_failure_message( + dmg_path: &str, + mount_point: &str, + status: impl std::fmt::Display, +) -> String { + format!( + "Failed to attach DMG image at {dmg_path}: {status}. A stale mount at {mount_point} may remain from a previous update; detach it with `hdiutil detach {mount_point}` or restart and retry." + ) +} + fn attach_dmg(dmg_path: &str, mount_point: &'static str) -> ResultType { let status = Command::new("hdiutil") .args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) .status()?; if !status.success() { - bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); + bail!( + "{}", + attach_dmg_failure_message(dmg_path, mount_point, status) + ); } Ok(DmgGuard(mount_point)) @@ -1309,6 +1322,17 @@ mod tests { assert_eq!(mode, 0o700); std::fs::remove_file(file_path).unwrap(); } + + #[test] + fn test_attach_dmg_failure_message_mentions_stale_mount_point() { + let message = + attach_dmg_failure_message("/tmp/RustDesk.dmg", UPDATE_DMG_MOUNT_POINT, "failed"); + + assert!(message.contains("/tmp/RustDesk.dmg")); + assert!(message.contains(UPDATE_DMG_MOUNT_POINT)); + assert!(message.contains("stale mount")); + assert!(message.contains("hdiutil detach")); + } } #[inline] From 7a8a4a2a5490be514f42f6b078b64f1e5480f6a7 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 18:35:18 +0800 Subject: [PATCH 03/17] fix(update): remove used variable Signed-off-by: fufesou --- src/platform/windows.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 5f9cf0718..04a72cc2a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3696,7 +3696,6 @@ fn update_file_extension(file: &str) -> Option { } fn verified_update_file_path(file: &str) -> ResultType { - let path = Path::new(file); let extension = update_file_extension(file).unwrap_or_default(); if extension != "exe" && extension != "msi" { bail!("Unsupported update file format: {}", file); From 5aa0deed914182a5b33e422e02c929a49abc5c39 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 19:02:05 +0800 Subject: [PATCH 04/17] fix(update): comments Signed-off-by: fufesou --- .../lib/desktop/widgets/update_progress.dart | 8 ++++---- src/hbbs_http/http_client.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index bde306316..879ecd745 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -65,7 +65,8 @@ void handleUpdate(String releasePageUrl) { } void _showUpdateError(String releasePageUrl, String error, - {bool showRetry = true}) { + {String messageKey = 'download-new-version-failed-tip', + bool showRetry = true}) { debugPrint('Update error: $error'); final dialogManager = gFFI.dialogManager; @@ -88,8 +89,7 @@ void _showUpdateError(String releasePageUrl, String error, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - msgboxContent('custom-nocancel-nook-hasclose', 'Error', - 'download-new-version-failed-tip'), + msgboxContent('custom-nocancel-nook-hasclose', 'Error', messageKey), const SizedBox(height: 8), Text(error), ], @@ -270,7 +270,7 @@ class UpdateProgressState extends State { } if (evt.containsKey('error')) { _showUpdateError(widget.releasePageUrl, evt['error'] as String, - showRetry: false); + messageKey: 'Failed', showRetry: false); } }, replace: true); bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl); diff --git a/src/hbbs_http/http_client.rs b/src/hbbs_http/http_client.rs index bd732bfcd..352982276 100644 --- a/src/hbbs_http/http_client.rs +++ b/src/hbbs_http/http_client.rs @@ -120,6 +120,15 @@ pub fn get_url_for_tls<'a>(url: &'a str, proxy_conf: &'a Option) - url } +/// Creates a sync HTTP client for `url`. +/// +/// `tls_danger_accept_invalid_cert` has three states: +/// - `None`: use cached TLS backend/cert settings when present; otherwise allow +/// automatic fallback between Rustls/NativeTls and strict/accept-invalid modes. +/// - `Some(false)`: force strict certificate validation, overriding the cache; +/// use for security-critical requests such as updates. +/// - `Some(true)`: force accepting invalid certificates, overriding the cache; +/// use sparingly. pub fn create_http_client_with_url( url: &str, tls_danger_accept_invalid_cert: Option, @@ -233,6 +242,15 @@ fn create_http_client_with_url_( client } +/// Creates an async HTTP client for `url`. +/// +/// `tls_danger_accept_invalid_cert` has three states: +/// - `None`: use cached TLS backend/cert settings when present; otherwise allow +/// automatic fallback between Rustls/NativeTls and strict/accept-invalid modes. +/// - `Some(false)`: force strict certificate validation, overriding the cache; +/// use for security-critical requests such as updates. +/// - `Some(true)`: force accepting invalid certificates, overriding the cache; +/// use sparingly. pub async fn create_http_client_async_with_url( url: &str, tls_danger_accept_invalid_cert: Option, From c1fdf5ab2c831112338f9aeb296ee90045a0c3e8 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 20:11:53 +0800 Subject: [PATCH 05/17] fix(update): refact logs Signed-off-by: fufesou --- src/platform/windows.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 04a72cc2a..a9c5e3078 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3873,7 +3873,7 @@ fn verify_update_file_signature_for_path(file: &str) -> ResultType<()> { } bail!( - "Update file signature verification failed for {}: {}{}", + "Update file signature verification failed for {}: . stderr: {}; stdout: {}", file, String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout) From 916477d6971a0472c1f88357c4f6b68e25d2ad94 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 20:42:43 +0800 Subject: [PATCH 06/17] fix(update): dialog cancel in flight & custom client update Signed-off-by: fufesou --- .../lib/desktop/widgets/update_progress.dart | 31 ++++++++++++------- src/platform/windows.rs | 30 ++++++++++-------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index 879ecd745..856ab9c9c 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -26,29 +26,38 @@ void handleUpdate(String releasePageUrl) { SimpleWrapper downloadId = SimpleWrapper(''); SimpleWrapper onCanceled = SimpleWrapper(() {}); SimpleWrapper pendingCancel = SimpleWrapper(false); + SimpleWrapper cancelInFlight = SimpleWrapper(false); SimpleWrapper Function()> cancelDownload = SimpleWrapper(() async {}); gFFI.dialogManager.dismissAll(); gFFI.dialogManager.show((setState, close, context) { cancelDownload.value = () async { + if (cancelInFlight.value) { + return; + } final id = downloadId.value; if (id.isEmpty) { pendingCancel.value = true; return; } - pendingCancel.value = false; - onCanceled.value(); - await bind.mainSetCommon(key: 'cancel-downloader', value: id); - // Wait for the downloader to be removed. - for (int i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 300)); - final isCanceled = 'error:Downloader not found' == - await bind.mainGetCommon(key: 'download-data-$id'); - if (isCanceled) { - break; + cancelInFlight.value = true; + try { + pendingCancel.value = false; + onCanceled.value(); + await bind.mainSetCommon(key: 'cancel-downloader', value: id); + // Wait for the downloader to be removed. + for (int i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 300)); + final isCanceled = 'error:Downloader not found' == + await bind.mainGetCommon(key: 'download-data-$id'); + if (isCanceled) { + break; + } } + close(); + } finally { + cancelInFlight.value = false; } - close(); }; return CustomAlertDialog( title: Text(translate('Downloading {$appName}')), diff --git a/src/platform/windows.rs b/src/platform/windows.rs index a9c5e3078..8f7518cd7 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3598,16 +3598,22 @@ pub fn handle_custom_client_staging_dir_before_update( } pub fn update_to_verified(file: &str, expected_sha256: &str) -> ResultType<()> { - match update_file_extension(file).as_deref() { - Some("exe") => { - let update_file = verify_update_file_signature_and_sha256(file, expected_sha256)?; - let custom_client_staging_dir = get_custom_client_staging_dir(); - if crate::is_custom_client() { - handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; - } else { - // Clean up any residual staging directory from previous custom client - allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); - } + let extension = update_file_extension(file).unwrap_or_default(); + if extension != "exe" && extension != "msi" { + bail!("Unsupported update file format: {}", file); + } + + let update_file = verify_update_file_signature_and_sha256(file, expected_sha256)?; + let custom_client_staging_dir = get_custom_client_staging_dir(); + if crate::is_custom_client() { + handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; + } else { + // Clean up any residual staging directory from previous custom client + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + } + + match extension.as_str() { + "exe" => { if !run_uac(update_file.path_str()?, "--update")? { bail!( "Failed to run the update exe with UAC, error: {:?}", @@ -3615,14 +3621,12 @@ pub fn update_to_verified(file: &str, expected_sha256: &str) -> ResultType<()> { ); } } - Some("msi") => { - let update_file = verify_update_file_signature_and_sha256(file, expected_sha256)?; + "msi" => { if let Err(e) = update_me_msi(update_file.path_str()?, false) { bail!("Failed to run the update msi: {}", e); } } _ => { - // unreachable!() bail!("Unsupported update file format: {}", file); } } From 8d32a0e01b04fe8493c63952b6639e635c6be2b0 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 21:18:12 +0800 Subject: [PATCH 07/17] fix(update): logs & tmp dir perms Signed-off-by: fufesou --- src/platform/macos.rs | 6 +++++- src/platform/windows.rs | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 7bc7233ab..3576b60ce 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -70,7 +70,11 @@ fn get_update_temp_dmg_dir() -> PathBuf { } fn create_update_temp_dmg_file() -> ResultType<(std::fs::File, PathBuf)> { - let dmg_dir = get_update_temp_dmg_dir(); + let update_temp_dir = get_update_temp_dir(); + std::fs::create_dir_all(&update_temp_dir)?; + std::fs::set_permissions(&update_temp_dir, std::fs::Permissions::from_mode(0o700))?; + + let dmg_dir = update_temp_dir.join("dmgdir"); std::fs::create_dir_all(&dmg_dir)?; std::fs::set_permissions(&dmg_dir, std::fs::Permissions::from_mode(0o700))?; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8f7518cd7..7903182c0 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3876,11 +3876,14 @@ fn verify_update_file_signature_for_path(file: &str) -> ResultType<()> { ); } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); bail!( - "Update file signature verification failed for {}: . stderr: {}; stdout: {}", + "Update file signature verification failed for {}: status: {}; stderr: {}; stdout: {}", file, - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) + output.status, + stderr.trim(), + stdout.trim() ); } From ee78cc18fd33f231b82d09c044025c21b9fa338d Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 21:47:23 +0800 Subject: [PATCH 08/17] fix(update): improve verified update cleanup and diagnostics Signed-off-by: fufesou --- src/platform/macos.rs | 26 ++++++++++++++++++++++---- src/platform/windows.rs | 10 ++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 3576b60ce..c49884ad6 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -343,8 +343,17 @@ fn update_daemon_agent( bail!("run osascript failed: {}", e); } Ok(output) if !output.status.success() => { - log::warn!("run osascript failed with status: {}", output.status); - bail!("run osascript failed with status: {}", output.status); + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!( + "run osascript failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); + bail!( + "run osascript failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); } _ => { let installed = std::path::Path::new(&agent_plist_file).exists(); @@ -1000,8 +1009,17 @@ fn update_me_from_app_dir(app_dir: String, keep_current_process: bool) -> Result .output(); match output { Ok(output) if !output.status.success() => { - log::error!("osascript execution failed with status: {}", output.status); - bail!("osascript execution failed with status: {}", output.status); + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!( + "osascript execution failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); + bail!( + "osascript execution failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); } Err(e) => { log::error!("run osascript failed: {}", e); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 7903182c0..38973a6d0 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3765,10 +3765,12 @@ fn copy_update_file_for_verification(file: &str) -> ResultType path.to_owned(), Err(e) => { - std::fs::remove_file(&update_file.path).ok(); + let path = update_file.path.clone(); + drop(update_file); + std::fs::remove_file(&path).ok(); return Err(e); } }; if let Err(e) = verify_update_file_sha256(&mut update_file.file, expected_sha256, &update_path) .and_then(|_| verify_update_file_signature_for_path(&update_path)) { - std::fs::remove_file(&update_file.path).ok(); + let path = update_file.path.clone(); + drop(update_file); + std::fs::remove_file(&path).ok(); return Err(e); } Ok(update_file) From c6f4c351551414ddccd90d861bd581a09d65e049 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 22:41:24 +0800 Subject: [PATCH 09/17] fix(update): fix(updater): fetch and cache SHA256 before manual update download Signed-off-by: fufesou --- .../lib/desktop/widgets/update_progress.dart | 24 +++- src/flutter_ffi.rs | 31 +++-- src/updater.rs | 108 +++++++++++++++++- 3 files changed, 146 insertions(+), 17 deletions(-) diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index 856ab9c9c..b1a522a2a 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -9,6 +9,12 @@ import 'package:url_launcher/url_launcher.dart'; const _eventKeyUpdateMe = 'update-me'; const _eventKeyUpdateMeReady = 'update-me-ready'; +const _githubRateLimitErrorMarker = + 'GitHub API rate limit may have been reached'; +// Since this is a rare case, +// we will not add a translation for this user message and will simply show it in English. +const _githubRateLimitUserMessage = + 'The download frequency limit may have been reached. Please try again later.'; void handleUpdate(String releasePageUrl) { String downloadUrl = releasePageUrl.replaceAll('tag', 'download'); @@ -75,9 +81,12 @@ void handleUpdate(String releasePageUrl) { void _showUpdateError(String releasePageUrl, String error, {String messageKey = 'download-new-version-failed-tip', - bool showRetry = true}) { + bool showRetry = true, + bool showErrorDetail = true, + String? userMessage}) { debugPrint('Update error: $error'); final dialogManager = gFFI.dialogManager; + final visibleError = userMessage ?? (showErrorDetail ? error : null); jumplink() { launchUrl(Uri.parse(releasePageUrl)); @@ -99,8 +108,10 @@ void _showUpdateError(String releasePageUrl, String error, crossAxisAlignment: CrossAxisAlignment.start, children: [ msgboxContent('custom-nocancel-nook-hasclose', 'Error', messageKey), - const SizedBox(height: 8), - Text(error), + if (visibleError != null) ...[ + const SizedBox(height: 8), + Text(visibleError), + ], ], ), ), @@ -198,7 +209,12 @@ class UpdateProgressState extends State { void _onError(String error) { cancelQueryTimer(); - _showUpdateError(widget.releasePageUrl, error); + if (error.contains(_githubRateLimitErrorMarker)) { + _showUpdateError(widget.releasePageUrl, error, + userMessage: _githubRateLimitUserMessage); + } else { + _showUpdateError(widget.releasePageUrl, error, showErrorDetail: false); + } } void _updateDownloadData() { diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index ed63b9879..1afa77468 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2932,14 +2932,27 @@ pub fn main_set_common(_key: String, _value: String) { let download_url = _value.clone(); let event_key = "download-new-version".to_owned(); let data = if let Some(download_file) = get_download_file_from_url(&download_url) { - std::fs::remove_file(&download_file).ok(); - match crate::hbbs_http::downloader::download_file( - download_url, - Some(PathBuf::from(download_file)), - Some(Duration::from_secs(3)), - ) { - Ok(id) => HashMap::from([("name", event_key), ("id", id)]), - Err(e) => HashMap::from([("name", event_key), ("error", e.to_string())]), + match crate::updater::download_file_expected_sha256(&download_url) { + Ok(_) => { + std::fs::remove_file(&download_file).ok(); + match crate::hbbs_http::downloader::download_file( + download_url, + Some(PathBuf::from(download_file)), + Some(Duration::from_secs(3)), + ) { + Ok(id) => HashMap::from([("name", event_key), ("id", id)]), + Err(e) => { + HashMap::from([("name", event_key), ("error", e.to_string())]) + } + } + } + Err(e) => HashMap::from([ + ("name", event_key), + ( + "error", + format!("Failed to get new version file SHA256, {}", e), + ), + ]), } } else { HashMap::from([ @@ -2968,6 +2981,7 @@ pub fn main_set_common(_key: String, _value: String) { let error = format!("Failed to get new version file SHA256, {}", e); log::error!("{}", error); push_update_me_error(error); + crate::updater::clear_download_file_expected_sha256(&_value); fs::remove_file(f).ok(); return; } @@ -2990,6 +3004,7 @@ pub fn main_set_common(_key: String, _value: String) { let error = format!("Failed to update to new version, {}", e); log::error!("{}", error); push_update_me_error(error); + crate::updater::clear_download_file_expected_sha256(&_value); fs::remove_file(f).ok(); } } diff --git a/src/updater.rs b/src/updater.rs index 6f1e67aaa..ed51a8d83 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,6 +1,7 @@ use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url}; use hbb_common::{bail, config, log, ResultType}; use std::{ + collections::HashMap, io::{Read, Write}, path::{Path, PathBuf}, sync::{ @@ -18,6 +19,7 @@ enum UpdateMsg { lazy_static::lazy_static! { static ref TX_MSG : Mutex> = Mutex::new(start_auto_update_check()); + static ref DOWNLOAD_FILE_SHA256_CACHE: Mutex> = Default::default(); } static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); @@ -430,16 +432,75 @@ fn fetch_github_release_metadata(api_url: &str) -> ResultType { .header(reqwest::header::USER_AGENT, "rustdesk-updater") .send()?; if !response.status().is_success() { - bail!( - "Failed to get GitHub release metadata: {}", - response.status() - ); + let status = response.status(); + if status == reqwest::StatusCode::FORBIDDEN + || status == reqwest::StatusCode::TOO_MANY_REQUESTS + { + bail!( + "Failed to get GitHub release metadata: {}. GitHub API rate limit may have been reached. Please retry later or download from the release page.", + status + ); + } + bail!("Failed to get GitHub release metadata: {}", status); } Ok(response.text()?) } +fn normalize_sha256_hex(sha256: &str) -> ResultType { + let sha256 = sha256.trim().to_ascii_lowercase(); + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("Update file SHA256 is malformed"); + } + Ok(sha256) +} + +fn cache_download_file_expected_sha256( + download_url: &str, + expected_sha256: &str, +) -> ResultType { + let expected_sha256 = normalize_sha256_hex(expected_sha256)?; + DOWNLOAD_FILE_SHA256_CACHE + .lock() + .unwrap() + .insert(download_url.to_owned(), expected_sha256.clone()); + Ok(expected_sha256) +} + +fn cached_download_file_expected_sha256(download_url: &str) -> Option { + DOWNLOAD_FILE_SHA256_CACHE + .lock() + .unwrap() + .get(download_url) + .cloned() +} + +pub fn clear_download_file_expected_sha256(download_url: &str) { + DOWNLOAD_FILE_SHA256_CACHE + .lock() + .unwrap() + .remove(download_url); +} + +pub fn refresh_download_file_expected_sha256(download_url: &str) -> ResultType { + let expected_sha256 = fetch_github_asset_sha256(download_url, download_url)?; + cache_download_file_expected_sha256(download_url, &expected_sha256) +} + pub fn download_file_expected_sha256(download_url: &str) -> ResultType { - fetch_github_asset_sha256(download_url, download_url) + match refresh_download_file_expected_sha256(download_url) { + Ok(expected_sha256) => Ok(expected_sha256), + Err(e) => { + if let Some(expected_sha256) = cached_download_file_expected_sha256(download_url) { + log::warn!( + "Failed to refresh update file SHA256 for {}, using cached value: {}", + download_url, + e + ); + return Ok(expected_sha256); + } + Err(e) + } + } } fn github_release_api_url(update_url: &str) -> ResultType { @@ -634,6 +695,43 @@ mod tests { assert!(github_release_asset_sha256(malformed, "rustdesk.exe").is_err()); } + #[test] + fn download_file_sha256_cache_roundtrips_and_clears() { + let download_url = format!( + "https://github.com/rustdesk/rustdesk/releases/download/test/rustdesk-cache-test-{}-{}.exe", + std::process::id(), + hbb_common::rand::random::() + ); + let expected_sha256 = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + clear_download_file_expected_sha256(&download_url); + + let cached = cache_download_file_expected_sha256(&download_url, expected_sha256).unwrap(); + + assert_eq!( + cached, + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + ); + assert_eq!( + cached_download_file_expected_sha256(&download_url), + Some(cached) + ); + clear_download_file_expected_sha256(&download_url); + assert_eq!(cached_download_file_expected_sha256(&download_url), None); + } + + #[test] + fn download_file_sha256_cache_rejects_malformed_digest() { + let download_url = format!( + "https://github.com/rustdesk/rustdesk/releases/download/test/rustdesk-cache-test-{}-{}.exe", + std::process::id(), + hbb_common::rand::random::() + ); + clear_download_file_expected_sha256(&download_url); + + assert!(cache_download_file_expected_sha256(&download_url, "sha256:not-hex").is_err()); + assert_eq!(cached_download_file_expected_sha256(&download_url), None); + } + #[test] fn verify_file_sha256_rejects_mismatched_file() { let file_path = std::env::temp_dir().join(format!( From e354a8af3edcbfd3d2fdca5f5155deb2cdc84262 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 9 May 2026 23:27:50 +0800 Subject: [PATCH 10/17] fix(update): http, tls, accept invalid certs Signed-off-by: fufesou --- src/hbbs_http/downloader.rs | 12 ++++++++-- src/hbbs_http/http_client.rs | 46 ++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index a1b16590f..e2549f2c1 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -69,7 +69,11 @@ pub fn download_file( if let Some(p) = stale_path { if p.exists() { if let Err(e) = std::fs::remove_file(&p) { - log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + log::warn!( + "Failed to remove stale download file {}: {}", + p.display(), + e + ); } } } @@ -108,7 +112,11 @@ pub fn download_file( if let Some(p) = stale_path_after_check { if p.exists() { if let Err(e) = std::fs::remove_file(&p) { - log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + log::warn!( + "Failed to remove stale download file {}: {}", + p.display(), + e + ); } } } diff --git a/src/hbbs_http/http_client.rs b/src/hbbs_http/http_client.rs index 352982276..875c90439 100644 --- a/src/hbbs_http/http_client.rs +++ b/src/hbbs_http/http_client.rs @@ -140,13 +140,14 @@ pub fn create_http_client_with_url( let tls_type = tls_type.unwrap_or(TlsType::Rustls); let danger_accept_invalid_cert = tls_danger_accept_invalid_cert.or_else(|| get_cached_tls_accept_invalid_cert(tls_url)); + let allow_accept_invalid_fallback = danger_accept_invalid_cert.is_none(); create_http_client_with_url_( url, tls_url, tls_type, is_tls_type_cached, danger_accept_invalid_cert, - tls_danger_accept_invalid_cert, + allow_accept_invalid_fallback, ) } @@ -156,16 +157,16 @@ fn create_http_client_with_url_( tls_type: TlsType, is_tls_type_cached: bool, danger_accept_invalid_cert: Option, - original_danger_accept_invalid_cert: Option, + allow_accept_invalid_fallback: bool, ) -> SyncClient { let mut client = create_http_client(tls_type, danger_accept_invalid_cert.unwrap_or(false)); - if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() { + if is_tls_type_cached && !allow_accept_invalid_fallback { return client; } if let Err(e) = client.head(url).send() { if e.is_request() { match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { - (TlsType::Rustls, _, None) => { + (TlsType::Rustls, _, None) if allow_accept_invalid_fallback => { log::warn!( "Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert", tls_url, @@ -177,7 +178,7 @@ fn create_http_client_with_url_( tls_type, is_tls_type_cached, Some(true), - original_danger_accept_invalid_cert, + allow_accept_invalid_fallback, ); } (TlsType::Rustls, false, Some(_)) => { @@ -191,11 +192,15 @@ fn create_http_client_with_url_( tls_url, TlsType::NativeTls, is_tls_type_cached, - original_danger_accept_invalid_cert, - original_danger_accept_invalid_cert, + if allow_accept_invalid_fallback { + None + } else { + danger_accept_invalid_cert + }, + allow_accept_invalid_fallback, ); } - (TlsType::NativeTls, _, None) => { + (TlsType::NativeTls, _, None) if allow_accept_invalid_fallback => { log::warn!( "Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert", tls_url, @@ -207,7 +212,7 @@ fn create_http_client_with_url_( tls_type, is_tls_type_cached, Some(true), - original_danger_accept_invalid_cert, + allow_accept_invalid_fallback, ); } _ => { @@ -262,13 +267,14 @@ pub async fn create_http_client_async_with_url( let tls_type = tls_type.unwrap_or(TlsType::Rustls); let danger_accept_invalid_cert = tls_danger_accept_invalid_cert.or_else(|| get_cached_tls_accept_invalid_cert(tls_url)); + let allow_accept_invalid_fallback = danger_accept_invalid_cert.is_none(); create_http_client_async_with_url_( url, tls_url, tls_type, is_tls_type_cached, danger_accept_invalid_cert, - tls_danger_accept_invalid_cert, + allow_accept_invalid_fallback, ) .await } @@ -280,16 +286,16 @@ async fn create_http_client_async_with_url_( tls_type: TlsType, is_tls_type_cached: bool, danger_accept_invalid_cert: Option, - original_danger_accept_invalid_cert: Option, + allow_accept_invalid_fallback: bool, ) -> AsyncClient { let mut client = create_http_client_async(tls_type, danger_accept_invalid_cert.unwrap_or(false)); - if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() { + if is_tls_type_cached && !allow_accept_invalid_fallback { return client; } if let Err(e) = client.head(url).send().await { match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { - (TlsType::Rustls, _, None) => { + (TlsType::Rustls, _, None) if allow_accept_invalid_fallback => { log::warn!( "Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert", tls_url, @@ -301,7 +307,7 @@ async fn create_http_client_async_with_url_( tls_type, is_tls_type_cached, Some(true), - original_danger_accept_invalid_cert, + allow_accept_invalid_fallback, ) .await; } @@ -316,12 +322,16 @@ async fn create_http_client_async_with_url_( tls_url, TlsType::NativeTls, is_tls_type_cached, - original_danger_accept_invalid_cert, - original_danger_accept_invalid_cert, + if allow_accept_invalid_fallback { + None + } else { + danger_accept_invalid_cert + }, + allow_accept_invalid_fallback, ) .await; } - (TlsType::NativeTls, _, None) => { + (TlsType::NativeTls, _, None) if allow_accept_invalid_fallback => { log::warn!( "Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert", tls_url, @@ -333,7 +343,7 @@ async fn create_http_client_async_with_url_( tls_type, is_tls_type_cached, Some(true), - original_danger_accept_invalid_cert, + allow_accept_invalid_fallback, ) .await; } From 48097d5ebe029df6b9954a59c2c87d6b671dec17 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 14:53:39 +0800 Subject: [PATCH 11/17] fix(update): improve update SHA256 caching and verification flow Signed-off-by: fufesou --- src/updater.rs | 95 ++++++++++++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/src/updater.rs b/src/updater.rs index ed51a8d83..e389b15dd 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -148,51 +148,15 @@ fn check_update(manually: bool) -> ResultType<()> { format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) }; log::debug!("New version available: {}", &version); - let client = create_http_client_with_url(&download_url, Some(false)); let Some(file_path) = get_download_file_from_url(&download_url) else { bail!("Failed to get the file path from the URL: {}", download_url); }; - let expected_sha256 = fetch_github_asset_sha256(&update_url, &download_url)?; - let mut is_file_exists = false; - if file_path.exists() { - // Check if the file size is the same as the server file size - // If the file size is the same, we don't need to download it again. - let file_size = std::fs::metadata(&file_path)?.len(); - let response = client.head(&download_url).send()?; - if !response.status().is_success() { - bail!("Failed to get the file size: {}", response.status()); - } - let total_size = response - .headers() - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|ct_len| ct_len.to_str().ok()) - .and_then(|ct_len| ct_len.parse::().ok()); - let Some(total_size) = total_size else { - bail!("Failed to get content length"); - }; - if file_size == total_size { - match verify_file_sha256(&file_path, &expected_sha256) { - Ok(()) => is_file_exists = true, - Err(e) => { - log::warn!("Removing cached update file with invalid SHA256: {}", e); - std::fs::remove_file(&file_path)?; - } - } - } else { - std::fs::remove_file(&file_path)?; - } - } - if !is_file_exists { - let response = client.get(&download_url).send()?; - if !response.status().is_success() { - bail!( - "Failed to download the new version file: {}", - response.status() - ); - } - let file_data = response.bytes()?; - write_verified_download(&file_path, &file_data, &expected_sha256)?; + let expected_sha256 = download_file_expected_sha256(&download_url)?; + let verify_res = ensure_verified_update_file(&download_url, &file_path, &expected_sha256); + if verify_res.is_err() { + clear_download_file_expected_sha256(&download_url); } + verify_res?; // We have checked if the `conns` is empty before, but we need to check again. // No need to care about the downloaded file here, because it's rare case that the `conns` are empty // before the download, but not empty after the download. @@ -204,6 +168,55 @@ fn check_update(manually: bool) -> ResultType<()> { Ok(()) } +fn ensure_verified_update_file( + download_url: &str, + file_path: &Path, + expected_sha256: &str, +) -> ResultType<()> { + let client = create_http_client_with_url(download_url, Some(false)); + let mut is_file_exists = false; + if file_path.exists() { + // Check if the file size is the same as the server file size + // If the file size is the same, we don't need to download it again. + let file_size = std::fs::metadata(file_path)?.len(); + let response = client.head(download_url).send()?; + if !response.status().is_success() { + bail!("Failed to get the file size: {}", response.status()); + } + let total_size = response + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|ct_len| ct_len.to_str().ok()) + .and_then(|ct_len| ct_len.parse::().ok()); + let Some(total_size) = total_size else { + bail!("Failed to get content length"); + }; + if file_size == total_size { + match verify_file_sha256(file_path, expected_sha256) { + Ok(()) => is_file_exists = true, + Err(e) => { + log::warn!("Removing cached update file with invalid SHA256: {}", e); + std::fs::remove_file(file_path)?; + } + } + } else { + std::fs::remove_file(file_path)?; + } + } + if !is_file_exists { + let response = client.get(download_url).send()?; + if !response.status().is_success() { + bail!( + "Failed to download the new version file: {}", + response.status() + ); + } + let file_data = response.bytes()?; + write_verified_download(file_path, &file_data, expected_sha256)?; + } + Ok(()) +} + #[cfg(target_os = "windows")] fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf, expected_sha256: &str) { log::debug!( From 5eb13d5fa2b16d319dbd89832fc2e837ef7371c6 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 19:31:44 +0800 Subject: [PATCH 12/17] fix(update): refact params, accept invalid certs Signed-off-by: fufesou --- src/hbbs_http/account.rs | 2 +- src/hbbs_http/downloader.rs | 2 +- src/hbbs_http/http_client.rs | 128 +++++++++++++++++++-------------- src/hbbs_http/record_upload.rs | 2 +- src/updater.rs | 4 +- 5 files changed, 79 insertions(+), 59 deletions(-) diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 5d21db9c9..81a1e145b 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -153,7 +153,7 @@ impl OidcSession { } // This URL is used to detect the appropriate TLS implementation for the server. let login_option_url = format!("{}/api/login-options", api_server); - let _ = create_http_client_with_url(&login_option_url, None); + let _ = create_http_client_with_url(&login_option_url, false); write_guard.warmed_api_server = Some(api_server.to_owned()); } diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index e2549f2c1..ac4bcd5eb 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -175,7 +175,7 @@ async fn do_download( auto_del_dur: Option, mut rx_cancel: UnboundedReceiver<()>, ) -> ResultType { - let client = create_http_client_async_with_url(&url, Some(false)).await; + let client = create_http_client_async_with_url(&url, true).await; let mut is_all_downloaded = false; tokio::select! { diff --git a/src/hbbs_http/http_client.rs b/src/hbbs_http/http_client.rs index 875c90439..acbf2f784 100644 --- a/src/hbbs_http/http_client.rs +++ b/src/hbbs_http/http_client.rs @@ -120,34 +120,38 @@ pub fn get_url_for_tls<'a>(url: &'a str, proxy_conf: &'a Option) - url } +fn resolve_danger_accept_invalid_cert( + force_strict_tls: bool, + cached_danger_accept_invalid_cert: Option, +) -> Option { + if force_strict_tls { + Some(false) + } else { + cached_danger_accept_invalid_cert + } +} + /// Creates a sync HTTP client for `url`. /// -/// `tls_danger_accept_invalid_cert` has three states: -/// - `None`: use cached TLS backend/cert settings when present; otherwise allow -/// automatic fallback between Rustls/NativeTls and strict/accept-invalid modes. -/// - `Some(false)`: force strict certificate validation, overriding the cache; -/// use for security-critical requests such as updates. -/// - `Some(true)`: force accepting invalid certificates, overriding the cache; -/// use sparingly. -pub fn create_http_client_with_url( - url: &str, - tls_danger_accept_invalid_cert: Option, -) -> SyncClient { +/// Set `force_strict_tls` to `true` for security-critical requests that must +/// reject invalid certificates and ignore the cached certificate policy. +pub fn create_http_client_with_url(url: &str, force_strict_tls: bool) -> SyncClient { let proxy_conf = Config::get_socks(); let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); let is_tls_type_cached = tls_type.is_some(); let tls_type = tls_type.unwrap_or(TlsType::Rustls); - let danger_accept_invalid_cert = - tls_danger_accept_invalid_cert.or_else(|| get_cached_tls_accept_invalid_cert(tls_url)); - let allow_accept_invalid_fallback = danger_accept_invalid_cert.is_none(); + let danger_accept_invalid_cert = resolve_danger_accept_invalid_cert( + force_strict_tls, + get_cached_tls_accept_invalid_cert(tls_url), + ); create_http_client_with_url_( url, tls_url, tls_type, is_tls_type_cached, danger_accept_invalid_cert, - allow_accept_invalid_fallback, + danger_accept_invalid_cert, ) } @@ -157,16 +161,16 @@ fn create_http_client_with_url_( tls_type: TlsType, is_tls_type_cached: bool, danger_accept_invalid_cert: Option, - allow_accept_invalid_fallback: bool, + original_danger_accept_invalid_cert: Option, ) -> SyncClient { let mut client = create_http_client(tls_type, danger_accept_invalid_cert.unwrap_or(false)); - if is_tls_type_cached && !allow_accept_invalid_fallback { + if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() { return client; } if let Err(e) = client.head(url).send() { if e.is_request() { match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { - (TlsType::Rustls, _, None) if allow_accept_invalid_fallback => { + (TlsType::Rustls, _, None) => { log::warn!( "Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert", tls_url, @@ -178,7 +182,7 @@ fn create_http_client_with_url_( tls_type, is_tls_type_cached, Some(true), - allow_accept_invalid_fallback, + original_danger_accept_invalid_cert, ); } (TlsType::Rustls, false, Some(_)) => { @@ -192,15 +196,11 @@ fn create_http_client_with_url_( tls_url, TlsType::NativeTls, is_tls_type_cached, - if allow_accept_invalid_fallback { - None - } else { - danger_accept_invalid_cert - }, - allow_accept_invalid_fallback, + original_danger_accept_invalid_cert, + original_danger_accept_invalid_cert, ); } - (TlsType::NativeTls, _, None) if allow_accept_invalid_fallback => { + (TlsType::NativeTls, _, None) => { log::warn!( "Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert", tls_url, @@ -212,7 +212,7 @@ fn create_http_client_with_url_( tls_type, is_tls_type_cached, Some(true), - allow_accept_invalid_fallback, + original_danger_accept_invalid_cert, ); } _ => { @@ -249,32 +249,25 @@ fn create_http_client_with_url_( /// Creates an async HTTP client for `url`. /// -/// `tls_danger_accept_invalid_cert` has three states: -/// - `None`: use cached TLS backend/cert settings when present; otherwise allow -/// automatic fallback between Rustls/NativeTls and strict/accept-invalid modes. -/// - `Some(false)`: force strict certificate validation, overriding the cache; -/// use for security-critical requests such as updates. -/// - `Some(true)`: force accepting invalid certificates, overriding the cache; -/// use sparingly. -pub async fn create_http_client_async_with_url( - url: &str, - tls_danger_accept_invalid_cert: Option, -) -> AsyncClient { +/// Set `force_strict_tls` to `true` for security-critical requests that must +/// reject invalid certificates and ignore the cached certificate policy. +pub async fn create_http_client_async_with_url(url: &str, force_strict_tls: bool) -> AsyncClient { let proxy_conf = Config::get_socks(); let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); let is_tls_type_cached = tls_type.is_some(); let tls_type = tls_type.unwrap_or(TlsType::Rustls); - let danger_accept_invalid_cert = - tls_danger_accept_invalid_cert.or_else(|| get_cached_tls_accept_invalid_cert(tls_url)); - let allow_accept_invalid_fallback = danger_accept_invalid_cert.is_none(); + let danger_accept_invalid_cert = resolve_danger_accept_invalid_cert( + force_strict_tls, + get_cached_tls_accept_invalid_cert(tls_url), + ); create_http_client_async_with_url_( url, tls_url, tls_type, is_tls_type_cached, danger_accept_invalid_cert, - allow_accept_invalid_fallback, + danger_accept_invalid_cert, ) .await } @@ -286,16 +279,16 @@ async fn create_http_client_async_with_url_( tls_type: TlsType, is_tls_type_cached: bool, danger_accept_invalid_cert: Option, - allow_accept_invalid_fallback: bool, + original_danger_accept_invalid_cert: Option, ) -> AsyncClient { let mut client = create_http_client_async(tls_type, danger_accept_invalid_cert.unwrap_or(false)); - if is_tls_type_cached && !allow_accept_invalid_fallback { + if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() { return client; } if let Err(e) = client.head(url).send().await { match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { - (TlsType::Rustls, _, None) if allow_accept_invalid_fallback => { + (TlsType::Rustls, _, None) => { log::warn!( "Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert", tls_url, @@ -307,7 +300,7 @@ async fn create_http_client_async_with_url_( tls_type, is_tls_type_cached, Some(true), - allow_accept_invalid_fallback, + original_danger_accept_invalid_cert, ) .await; } @@ -322,16 +315,12 @@ async fn create_http_client_async_with_url_( tls_url, TlsType::NativeTls, is_tls_type_cached, - if allow_accept_invalid_fallback { - None - } else { - danger_accept_invalid_cert - }, - allow_accept_invalid_fallback, + original_danger_accept_invalid_cert, + original_danger_accept_invalid_cert, ) .await; } - (TlsType::NativeTls, _, None) if allow_accept_invalid_fallback => { + (TlsType::NativeTls, _, None) => { log::warn!( "Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert", tls_url, @@ -343,7 +332,7 @@ async fn create_http_client_async_with_url_( tls_type, is_tls_type_cached, Some(true), - allow_accept_invalid_fallback, + original_danger_accept_invalid_cert, ) .await; } @@ -370,3 +359,34 @@ async fn create_http_client_async_with_url_( } client } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn force_strict_tls_overrides_cached_cert_policy() { + assert_eq!(resolve_danger_accept_invalid_cert(true, None), Some(false)); + assert_eq!( + resolve_danger_accept_invalid_cert(true, Some(false)), + Some(false) + ); + assert_eq!( + resolve_danger_accept_invalid_cert(true, Some(true)), + Some(false) + ); + } + + #[test] + fn non_strict_tls_uses_cached_cert_policy() { + assert_eq!(resolve_danger_accept_invalid_cert(false, None), None); + assert_eq!( + resolve_danger_accept_invalid_cert(false, Some(false)), + Some(false) + ); + assert_eq!( + resolve_danger_accept_invalid_cert(false, Some(true)), + Some(true) + ); + } +} diff --git a/src/hbbs_http/record_upload.rs b/src/hbbs_http/record_upload.rs index b841368d4..3c6dba94e 100644 --- a/src/hbbs_http/record_upload.rs +++ b/src/hbbs_http/record_upload.rs @@ -32,7 +32,7 @@ pub fn run(rx: Receiver) { ); // This URL is used for TLS connectivity testing and fallback detection. let login_option_url = format!("{}/api/login-options", &api_server); - let client = create_http_client_with_url(&login_option_url, None); + let client = create_http_client_with_url(&login_option_url, false); let mut uploader = RecordUploader { client, api_server, diff --git a/src/updater.rs b/src/updater.rs index e389b15dd..07b2b2d56 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -173,7 +173,7 @@ fn ensure_verified_update_file( file_path: &Path, expected_sha256: &str, ) -> ResultType<()> { - let client = create_http_client_with_url(download_url, Some(false)); + let client = create_http_client_with_url(download_url, true); let mut is_file_exists = false; if file_path.exists() { // Check if the file size is the same as the server file size @@ -439,7 +439,7 @@ fn fetch_github_asset_sha256(update_url: &str, download_url: &str) -> ResultType } fn fetch_github_release_metadata(api_url: &str) -> ResultType { - let client = create_http_client_with_url(&api_url, Some(false)); + let client = create_http_client_with_url(&api_url, true); let response = client .get(api_url) .header(reqwest::header::USER_AGENT, "rustdesk-updater") From 516fd5e15b1a1072536f7d12971023446c76faef Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 19:53:10 +0800 Subject: [PATCH 13/17] fix(update): Simple refactor, helper function Signed-off-by: fufesou --- src/updater.rs | 73 +++++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/src/updater.rs b/src/updater.rs index 07b2b2d56..1eafb958e 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -217,6 +217,33 @@ fn ensure_verified_update_file( Ok(()) } +#[cfg(target_os = "windows")] +fn verified_update_path( + p: &str, + expected_sha256: &str, + kind: &str, + file_path: &Path, +) -> Option<(crate::platform::VerifiedUpdateFile, String)> { + let update_file = + match crate::platform::verify_update_file_signature_and_sha256(p, expected_sha256) { + Ok(update_file) => update_file, + Err(e) => { + log::error!("Refusing to update from untrusted {}: {}", kind, e); + std::fs::remove_file(file_path).ok(); + return None; + } + }; + let update_path = match update_file.path_str() { + Ok(path) => path.to_owned(), + Err(e) => { + log::error!("Failed to get verified {} path: {}", kind, e); + std::fs::remove_file(file_path).ok(); + return None; + } + }; + Some((update_file, update_path)) +} + #[cfg(target_os = "windows")] fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf, expected_sha256: &str) { log::debug!( @@ -226,26 +253,12 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf, expe if let Some(p) = file_path.to_str() { if let Some(session_id) = crate::platform::get_current_process_session_id() { if update_msi { - let update_file = match crate::platform::verify_update_file_signature_and_sha256( - p, - expected_sha256, - ) { - Ok(update_file) => update_file, - Err(e) => { - log::error!("Refusing to update from untrusted msi: {}", e); - std::fs::remove_file(&file_path).ok(); - return; - } + let Some((_update_file, update_path)) = + verified_update_path(p, expected_sha256, "msi", file_path) + else { + return; }; - let update_path = match update_file.path_str() { - Ok(path) => path, - Err(e) => { - log::error!("Failed to get verified msi path: {}", e); - std::fs::remove_file(&file_path).ok(); - return; - } - }; - match crate::platform::update_me_msi(update_path, true) { + match crate::platform::update_me_msi(&update_path, true) { Ok(_) => { log::debug!("New version \"{}\" updated.", version); } @@ -259,24 +272,10 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf, expe } } } else { - let update_file = match crate::platform::verify_update_file_signature_and_sha256( - p, - expected_sha256, - ) { - Ok(update_file) => update_file, - Err(e) => { - log::error!("Refusing to update from untrusted exe: {}", e); - std::fs::remove_file(&file_path).ok(); - return; - } - }; - let update_path = match update_file.path_str() { - Ok(path) => path, - Err(e) => { - log::error!("Failed to get verified exe path: {}", e); - std::fs::remove_file(&file_path).ok(); - return; - } + let Some((_update_file, update_path)) = + verified_update_path(p, expected_sha256, "exe", file_path) + else { + return; }; let custom_client_staging_dir = if crate::is_custom_client() { let custom_client_staging_dir = From 8c1ece5c7352c1a7b2d6244bd79c28387a532bb2 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 21:12:36 +0800 Subject: [PATCH 14/17] fix(update): verified update download handling - Fetch and cache update SHA256 before download - Stream downloaded update files and verify before install - Add bounded timeouts for metadata and HEAD requests - Hide internal update errors from the user-facing dialog - Fix Windows update SHA256 test cleanup Signed-off-by: fufesou --- .../lib/desktop/widgets/update_progress.dart | 2 +- src/platform/windows.rs | 6 +- src/updater.rs | 70 +++++++++++++++---- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index b1a522a2a..8488ecb28 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -295,7 +295,7 @@ class UpdateProgressState extends State { } if (evt.containsKey('error')) { _showUpdateError(widget.releasePageUrl, evt['error'] as String, - messageKey: 'Failed', showRetry: false); + showRetry: false, showErrorDetail: false); } }, replace: true); bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 38973a6d0..3b40b37e1 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -4851,8 +4851,9 @@ mod tests { &update_file_path, ); - let _ = std::fs::remove_dir_all(&test_dir); assert!(result.is_ok()); + drop(update_file); + std::fs::remove_dir_all(&test_dir).unwrap(); } #[test] @@ -4875,8 +4876,9 @@ mod tests { &update_file_path, ); - let _ = std::fs::remove_dir_all(&test_dir); assert!(result.is_err()); + drop(update_file); + std::fs::remove_dir_all(&test_dir).unwrap(); } #[test] diff --git a/src/updater.rs b/src/updater.rs index 1eafb958e..807471eca 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -25,6 +25,7 @@ lazy_static::lazy_static! { static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); +const UPDATE_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); pub fn update_controlling_session_count(count: usize) { CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); @@ -179,7 +180,10 @@ fn ensure_verified_update_file( // Check if the file size is the same as the server file size // If the file size is the same, we don't need to download it again. let file_size = std::fs::metadata(file_path)?.len(); - let response = client.head(download_url).send()?; + let response = client + .head(download_url) + .timeout(UPDATE_HTTP_REQUEST_TIMEOUT) + .send()?; if !response.status().is_success() { bail!("Failed to get the file size: {}", response.status()); } @@ -204,15 +208,14 @@ fn ensure_verified_update_file( } } if !is_file_exists { - let response = client.get(download_url).send()?; + let mut response = client.get(download_url).send()?; if !response.status().is_success() { bail!( "Failed to download the new version file: {}", response.status() ); } - let file_data = response.bytes()?; - write_verified_download(file_path, &file_data, expected_sha256)?; + write_verified_download_from_reader(file_path, &mut response, expected_sha256)?; } Ok(()) } @@ -389,26 +392,24 @@ fn install_verified_download(temp_path: &Path, final_path: &Path) -> ResultType< Ok(()) } -fn write_and_verify_download_file( +fn copy_and_verify_download_file( file: &mut std::fs::File, temp_path: &Path, - file_data: &[u8], + reader: &mut R, expected_sha256: &str, ) -> ResultType<()> { - file.write_all(file_data)?; + std::io::copy(reader, file)?; file.flush()?; verify_file_sha256(temp_path, expected_sha256) } -fn write_verified_download( +fn write_verified_download_from_reader( final_path: &Path, - file_data: &[u8], + reader: &mut R, expected_sha256: &str, ) -> ResultType<()> { let (mut file, temp_path) = create_download_temp_file(final_path)?; - if let Err(e) = - write_and_verify_download_file(&mut file, &temp_path, file_data, expected_sha256) - { + if let Err(e) = copy_and_verify_download_file(&mut file, &temp_path, reader, expected_sha256) { std::fs::remove_file(temp_path).ok(); return Err(e); } @@ -419,6 +420,16 @@ fn write_verified_download( Ok(()) } +#[cfg(test)] +fn write_verified_download( + final_path: &Path, + file_data: &[u8], + expected_sha256: &str, +) -> ResultType<()> { + let mut reader = file_data; + write_verified_download_from_reader(final_path, &mut reader, expected_sha256) +} + #[derive(serde::Deserialize)] struct GithubRelease { assets: Vec, @@ -430,8 +441,11 @@ struct GithubReleaseAsset { digest: Option, } -fn fetch_github_asset_sha256(update_url: &str, download_url: &str) -> ResultType { - let api_url = github_release_api_url(update_url)?; +fn fetch_github_asset_sha256( + release_or_download_url: &str, + download_url: &str, +) -> ResultType { + let api_url = github_release_api_url(release_or_download_url)?; let asset_name = download_asset_name(download_url)?; let metadata = fetch_github_release_metadata(&api_url)?; github_release_asset_sha256(&metadata, &asset_name) @@ -442,6 +456,7 @@ fn fetch_github_release_metadata(api_url: &str) -> ResultType { let response = client .get(api_url) .header(reqwest::header::USER_AGENT, "rustdesk-updater") + .timeout(UPDATE_HTTP_REQUEST_TIMEOUT) .send()?; if !response.status().is_success() { let status = response.status(); @@ -707,6 +722,11 @@ mod tests { assert!(github_release_asset_sha256(malformed, "rustdesk.exe").is_err()); } + #[test] + fn update_http_request_timeout_is_bounded() { + assert_eq!(UPDATE_HTTP_REQUEST_TIMEOUT, Duration::from_secs(30)); + } + #[test] fn download_file_sha256_cache_roundtrips_and_clears() { let download_url = format!( @@ -808,6 +828,28 @@ mod tests { std::fs::remove_dir_all(&test_dir).unwrap(); } + #[test] + fn write_verified_download_from_reader_installs_verified_file() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-reader-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + let mut data: &[u8] = b"update"; + + write_verified_download_from_reader( + &final_path, + &mut data, + "2937013f2181810606b2a799b05bda2849f3e369a20982a4138f0e0a55984ce4", + ) + .unwrap(); + + assert_eq!(std::fs::read(&final_path).unwrap(), b"update"); + std::fs::remove_dir_all(&test_dir).unwrap(); + } + #[test] fn write_verified_download_removes_temp_file_on_install_error() { let test_dir = std::env::temp_dir().join(format!( From 04dd5d162e3b428661f867be7c85679cb3f5d4cd Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 10 May 2026 22:14:20 +0800 Subject: [PATCH 15/17] fix(update): better timeout Signed-off-by: fufesou --- src/hbbs_http/downloader.rs | 48 +++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index ac4bcd5eb..7240a1535 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -18,6 +18,9 @@ lazy_static! { static ref DOWNLOADERS: Mutex> = Default::default(); } +const DOWNLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(60); + /// This struct is used to return the download data to the caller. /// The caller should check if the file is downloaded successfully and remove the job from the map. /// If the file is not downloaded successfully, the `data` field will be empty. @@ -175,16 +178,24 @@ async fn do_download( auto_del_dur: Option, mut rx_cancel: UnboundedReceiver<()>, ) -> ResultType { - let client = create_http_client_async_with_url(&url, true).await; + let client = match tokio::time::timeout( + DOWNLOAD_REQUEST_TIMEOUT, + create_http_client_async_with_url(&url, true), + ) + .await + { + Ok(client) => client, + Err(_) => bail!("Timed out while creating download HTTP client"), + }; let mut is_all_downloaded = false; tokio::select! { _ = rx_cancel.recv() => { return Ok(is_all_downloaded); } - head_resp = client.head(&url).send() => { + head_resp = tokio::time::timeout(DOWNLOAD_REQUEST_TIMEOUT, client.head(&url).send()) => { match head_resp { - Ok(resp) => { + Ok(Ok(resp)) => { if resp.status().is_success() { let total_size = resp .headers() @@ -201,9 +212,10 @@ async fn do_download( bail!("Failed to get content length: {}", resp.status()); } } - Err(e) => { + Ok(Err(e)) => { return Err(e.into()); } + Err(_) => bail!("Timed out while getting download file size"), } } } @@ -213,8 +225,12 @@ async fn do_download( _ = rx_cancel.recv() => { return Ok(is_all_downloaded); } - resp = client.get(url).send() => { - response = resp?; + resp = tokio::time::timeout(DOWNLOAD_REQUEST_TIMEOUT, client.get(&url).send()) => { + match resp { + Ok(Ok(resp)) => response = resp, + Ok(Err(e)) => return Err(e.into()), + Err(_) => bail!("Timed out while starting download"), + } } } @@ -228,9 +244,9 @@ async fn do_download( _ = rx_cancel.recv() => { break; } - chunk = response.chunk() => { + chunk = tokio::time::timeout(DOWNLOAD_READ_TIMEOUT, response.chunk()) => { match chunk { - Ok(Some(chunk)) => { + Ok(Ok(Some(chunk))) => { match dest { Some(ref mut f) => { f.write_all(&chunk).await?; @@ -247,14 +263,15 @@ async fn do_download( } } } - Ok(None) => { + Ok(Ok(None)) => { is_all_downloaded = true; break; }, - Err(e) => { + Ok(Err(e)) => { log::error!("Download {} failed: {}", id, e); return Err(e.into()); } + Err(_) => bail!("Timed out while reading download data"), } } } @@ -315,3 +332,14 @@ pub fn cancel(id: &str) { pub fn remove(id: &str) { let _ = DOWNLOADERS.lock().unwrap().remove(id); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn download_timeouts_are_bounded() { + assert_eq!(DOWNLOAD_REQUEST_TIMEOUT, Duration::from_secs(30)); + assert_eq!(DOWNLOAD_READ_TIMEOUT, Duration::from_secs(60)); + } +} From 9fea95b8afff09cc698d88631475ef8693a2e272 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 11 May 2026 11:33:01 +0800 Subject: [PATCH 16/17] fix(update): early drop unused file Signed-off-by: fufesou --- src/updater.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/updater.rs b/src/updater.rs index 807471eca..896aaa69c 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -413,6 +413,7 @@ fn write_verified_download_from_reader( std::fs::remove_file(temp_path).ok(); return Err(e); } + drop(file); if let Err(e) = install_verified_download(&temp_path, final_path) { std::fs::remove_file(temp_path).ok(); return Err(e); From b40745b96a52bf7aa703f8d80f4ee1421f009465 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 12 May 2026 09:24:38 +0800 Subject: [PATCH 17/17] fix(update): windows, cmd no window Signed-off-by: fufesou --- src/platform/windows.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 3b40b37e1..a5e98f9b7 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3868,6 +3868,7 @@ fn verify_update_file_signature_for_path(file: &str) -> ResultType<()> { .args(["-NoProfile", "-NonInteractive", "-Command"]) .arg(AUTHENTICODE_VERIFICATION_SCRIPT) .env(UPDATE_FILE_ENV, file) + .creation_flags(CREATE_NO_WINDOW) .output()?; if output.status.success() { let publisher_name = String::from_utf8_lossy(&output.stdout);