Merge b40745b96a into 6ad56075d6
This commit is contained in:
commit
a5d8341732
10 changed files with 1875 additions and 323 deletions
|
|
@ -7,10 +7,16 @@ 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';
|
||||
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) {
|
||||
_isExtracting.value = false;
|
||||
String downloadUrl = releasePageUrl.replaceAll('tag', 'download');
|
||||
String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
|
||||
final String downloadFile =
|
||||
|
|
@ -23,46 +29,126 @@ void handleUpdate(String releasePageUrl) {
|
|||
}
|
||||
downloadUrl = '$downloadUrl/$downloadFile';
|
||||
|
||||
SimpleWrapper downloadId = SimpleWrapper('');
|
||||
SimpleWrapper<String> downloadId = SimpleWrapper('');
|
||||
SimpleWrapper<VoidCallback> onCanceled = SimpleWrapper(() {});
|
||||
SimpleWrapper<bool> pendingCancel = SimpleWrapper(false);
|
||||
SimpleWrapper<bool> cancelInFlight = SimpleWrapper(false);
|
||||
SimpleWrapper<Future<void> 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
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,
|
||||
{String messageKey = 'download-new-version-failed-tip',
|
||||
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));
|
||||
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', messageKey),
|
||||
if (visibleError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(visibleError),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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<String> downloadId;
|
||||
final SimpleWrapper<VoidCallback> onCanceled;
|
||||
final SimpleWrapper<bool> pendingCancel;
|
||||
final SimpleWrapper<Future<void> Function()> cancelDownload;
|
||||
UpdateProgress(this.releasePageUrl, this.downloadUrl, this.downloadId,
|
||||
this.onCanceled, this.pendingCancel, this.cancelDownload,
|
||||
{Key? key})
|
||||
: super(key: key);
|
||||
|
||||
|
|
@ -76,7 +162,6 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
int _downloadedSize = 0;
|
||||
int _getDataFailedCount = 0;
|
||||
final String _eventKeyDownloadNewVersion = 'download-new-version';
|
||||
final String _eventKeyExtractUpdateDmg = 'extract-update-dmg';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -88,11 +173,6 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
_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 +180,6 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
cancelQueryTimer();
|
||||
platformFFI.unregisterEventHandler(
|
||||
_eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion);
|
||||
if (isMacOS) {
|
||||
platformFFI.unregisterEventHandler(
|
||||
_eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +194,9 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
_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 +207,14 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
}
|
||||
}
|
||||
|
||||
// `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();
|
||||
if (error.contains(_githubRateLimitErrorMarker)) {
|
||||
_showUpdateError(widget.releasePageUrl, error,
|
||||
userMessage: _githubRateLimitUserMessage);
|
||||
} else {
|
||||
_showUpdateError(widget.releasePageUrl, error, showErrorDetail: false);
|
||||
}
|
||||
|
||||
jumplink() {
|
||||
launchUrl(Uri.parse(widget.releasePageUrl));
|
||||
dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
retry() {
|
||||
dialogManager.dismissAll();
|
||||
handleUpdate(widget.releasePageUrl);
|
||||
}
|
||||
|
||||
final List<Widget> 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',
|
||||
);
|
||||
}
|
||||
|
||||
void _updateDownloadData() {
|
||||
|
|
@ -212,13 +258,7 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
_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 +276,41 @@ class UpdateProgressState extends State<UpdateProgress> {
|
|||
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, showErrorDetail: false);
|
||||
}
|
||||
}, replace: true);
|
||||
bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl);
|
||||
},
|
||||
submitTimeout: 5,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleExtractUpdateDmg(Map<String, dynamic> 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],
|
||||
|
|
|
|||
|
|
@ -2874,6 +2874,24 @@ pub fn main_get_common_sync(key: String) -> SyncReturn<String> {
|
|||
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() {
|
||||
|
|
@ -2914,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([
|
||||
|
|
@ -2942,34 +2973,43 @@ 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);
|
||||
crate::updater::clear_download_file_expected_sha256(&_value);
|
||||
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);
|
||||
crate::updater::clear_download_file_expected_sha256(&_value);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, false);
|
||||
write_guard.warmed_api_server = Some(api_server.to_owned());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ lazy_static! {
|
|||
static ref DOWNLOADERS: Mutex<HashMap<String, Downloader>> = 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.
|
||||
|
|
@ -69,7 +72,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 +115,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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -167,16 +178,24 @@ async fn do_download(
|
|||
auto_del_dur: Option<Duration>,
|
||||
mut rx_cancel: UnboundedReceiver<()>,
|
||||
) -> ResultType<bool> {
|
||||
let client = create_http_client_async_with_url(&url).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()
|
||||
|
|
@ -193,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -205,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,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?;
|
||||
|
|
@ -239,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -307,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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,20 +120,38 @@ pub fn get_url_for_tls<'a>(url: &'a str, proxy_conf: &'a Option<Socks5Server>) -
|
|||
url
|
||||
}
|
||||
|
||||
pub fn create_http_client_with_url(url: &str) -> SyncClient {
|
||||
fn resolve_danger_accept_invalid_cert(
|
||||
force_strict_tls: bool,
|
||||
cached_danger_accept_invalid_cert: Option<bool>,
|
||||
) -> Option<bool> {
|
||||
if force_strict_tls {
|
||||
Some(false)
|
||||
} else {
|
||||
cached_danger_accept_invalid_cert
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a sync HTTP client for `url`.
|
||||
///
|
||||
/// 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 tls_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
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,
|
||||
tls_danger_accept_invalid_cert,
|
||||
tls_danger_accept_invalid_cert,
|
||||
danger_accept_invalid_cert,
|
||||
danger_accept_invalid_cert,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -229,13 +247,20 @@ fn create_http_client_with_url_(
|
|||
client
|
||||
}
|
||||
|
||||
pub async fn create_http_client_async_with_url(url: &str) -> AsyncClient {
|
||||
/// Creates an async HTTP client for `url`.
|
||||
///
|
||||
/// 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 = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
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,
|
||||
|
|
@ -334,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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub fn run(rx: Receiver<RecordState>) {
|
|||
);
|
||||
// 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, false);
|
||||
let mut uploader = RecordUploader {
|
||||
client,
|
||||
api_server,
|
||||
|
|
|
|||
|
|
@ -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 <mach/boolean.h>
|
||||
|
|
@ -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<PathBuf> = 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::<u64>()
|
||||
))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
|
@ -53,6 +65,43 @@ 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 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))?;
|
||||
|
||||
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::<u64>()
|
||||
));
|
||||
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 +299,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 +335,40 @@ 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() => {
|
||||
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();
|
||||
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 +889,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 +946,84 @@ 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() => {
|
||||
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);
|
||||
bail!("run osascript failed: {}", e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -906,32 +1048,122 @@ 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<fn()>,
|
||||
) -> 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);
|
||||
}
|
||||
fn update_from_verified_dmg(
|
||||
dmg_path: &str,
|
||||
expected_sha256: &str,
|
||||
before_prompt: Option<fn()>,
|
||||
) -> 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 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))
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
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_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<DmgGuard> {
|
||||
let status = Command::new("hdiutil")
|
||||
.args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path])
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"{}",
|
||||
attach_dmg_failure_message(dmg_path, mount_point, 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<fn()>) -> ResultType<()> {
|
||||
let _guard = attach_dmg(dmg_path, UPDATE_DMG_MOUNT_POINT)?;
|
||||
if let Some(before_prompt) = before_prompt {
|
||||
before_prompt();
|
||||
}
|
||||
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 mount_point = "/Volumes/RustDeskUpdate";
|
||||
let target_path = Path::new(target_dir);
|
||||
|
||||
if target_path.exists() {
|
||||
|
|
@ -939,26 +1171,10 @@ fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> {
|
|||
}
|
||||
std::fs::create_dir_all(target_path)?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
struct DmgGuard(&'static str);
|
||||
impl Drop for DmgGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(&["detach", self.0, "-force"])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
let _guard = DmgGuard(mount_point);
|
||||
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 +1201,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 +1223,140 @@ 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::<u64>()
|
||||
));
|
||||
let unrelated_dir = Path::new("/tmp").join(format!(
|
||||
".rustdeskupdate-cleanup-test-{}-{}",
|
||||
std::process::id(),
|
||||
hbb_common::rand::random::<u64>()
|
||||
));
|
||||
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::<u64>()
|
||||
));
|
||||
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();
|
||||
}
|
||||
|
||||
#[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]
|
||||
#[allow(dead_code)]
|
||||
fn get_server_start_time_of(p: &Process, path: &Path) -> Option<i64> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -3592,33 +3598,303 @@ 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));
|
||||
}
|
||||
if !run_uac(file, "--update")? {
|
||||
bail!(
|
||||
"Failed to run the update exe with UAC, error: {:?}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
} else if file.ends_with(".msi") {
|
||||
if let Err(e) = update_me_msi(file, false) {
|
||||
bail!("Failed to run the update msi: {}", e);
|
||||
}
|
||||
} else {
|
||||
// unreachable!()
|
||||
pub fn update_to_verified(file: &str, expected_sha256: &str) -> ResultType<()> {
|
||||
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: {:?}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
"msi" => {
|
||||
if let Err(e) = update_me_msi(update_file.path_str()?, false) {
|
||||
bail!("Failed to run the update msi: {}", e);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
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<String> {
|
||||
Path::new(file)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| extension.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn verified_update_file_path(file: &str) -> ResultType<PathBuf> {
|
||||
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::<u64>(),
|
||||
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<std::fs::File> {
|
||||
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<VerifiedUpdateFile> {
|
||||
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) {
|
||||
drop(copy_file);
|
||||
std::fs::remove_file(&path).ok();
|
||||
return Err(e.into());
|
||||
}
|
||||
if let Err(e) = copy_file.flush() {
|
||||
drop(copy_file);
|
||||
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<VerifiedUpdateFile> {
|
||||
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) => {
|
||||
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))
|
||||
{
|
||||
let path = update_file.path.clone();
|
||||
drop(update_file);
|
||||
std::fs::remove_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)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.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
|
||||
);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
bail!(
|
||||
"Update file signature verification failed for {}: status: {}; stderr: {}; stdout: {}",
|
||||
file,
|
||||
output.status,
|
||||
stderr.trim(),
|
||||
stdout.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -3631,7 +3907,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")?;
|
||||
|
|
@ -3730,10 +4006,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() {
|
||||
|
|
@ -4490,6 +4764,139 @@ pub(super) fn get_pids_with_first_arg_by_wmic<S1: AsRef<str>, S2: AsRef<str>>(
|
|||
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,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
drop(update_file);
|
||||
std::fs::remove_dir_all(&test_dir).unwrap();
|
||||
}
|
||||
|
||||
#[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,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
drop(update_file);
|
||||
std::fs::remove_dir_all(&test_dir).unwrap();
|
||||
}
|
||||
|
||||
#[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-only reusable Win32 HANDLE RAII helper.
|
||||
// If a future non-test path needs the same pattern, move it out of this test module.
|
||||
//
|
||||
|
|
|
|||
695
src/updater.rs
695
src/updater.rs
|
|
@ -1,8 +1,9 @@
|
|||
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,
|
||||
collections::HashMap,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc::{channel, Receiver, Sender},
|
||||
|
|
@ -18,11 +19,13 @@ enum UpdateMsg {
|
|||
|
||||
lazy_static::lazy_static! {
|
||||
static ref TX_MSG : Mutex<Sender<UpdateMsg>> = Mutex::new(start_auto_update_check());
|
||||
static ref DOWNLOAD_FILE_SHA256_CACHE: Mutex<HashMap<String, String>> = Default::default();
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -146,58 +149,106 @@ 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 Some(file_path) = get_download_file_from_url(&download_url) else {
|
||||
bail!("Failed to get the file path from the 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::<u64>().ok());
|
||||
let Some(total_size) = total_size else {
|
||||
bail!("Failed to get content length");
|
||||
};
|
||||
if file_size == total_size {
|
||||
is_file_exists = true;
|
||||
} 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()?;
|
||||
let mut file = std::fs::File::create(&file_path)?;
|
||||
file.write_all(&file_data)?;
|
||||
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.
|
||||
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(())
|
||||
}
|
||||
|
||||
fn ensure_verified_update_file(
|
||||
download_url: &str,
|
||||
file_path: &Path,
|
||||
expected_sha256: &str,
|
||||
) -> ResultType<()> {
|
||||
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
|
||||
// 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)
|
||||
.timeout(UPDATE_HTTP_REQUEST_TIMEOUT)
|
||||
.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::<u64>().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 mut response = client.get(download_url).send()?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"Failed to download the new version file: {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
write_verified_download_from_reader(file_path, &mut response, expected_sha256)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) {
|
||||
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!(
|
||||
"New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}",
|
||||
file_path.to_str()
|
||||
|
|
@ -205,7 +256,12 @@ 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 Some((_update_file, update_path)) =
|
||||
verified_update_path(p, expected_sha256, "msi", file_path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match crate::platform::update_me_msi(&update_path, true) {
|
||||
Ok(_) => {
|
||||
log::debug!("New version \"{}\" updated.", version);
|
||||
}
|
||||
|
|
@ -219,6 +275,11 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
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 =
|
||||
crate::platform::get_custom_client_staging_dir();
|
||||
|
|
@ -243,7 +304,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 +349,555 @@ pub fn get_download_file_from_url(url: &str) -> Option<PathBuf> {
|
|||
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::<u64>()
|
||||
));
|
||||
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 copy_and_verify_download_file<R: Read>(
|
||||
file: &mut std::fs::File,
|
||||
temp_path: &Path,
|
||||
reader: &mut R,
|
||||
expected_sha256: &str,
|
||||
) -> ResultType<()> {
|
||||
std::io::copy(reader, file)?;
|
||||
file.flush()?;
|
||||
verify_file_sha256(temp_path, expected_sha256)
|
||||
}
|
||||
|
||||
fn write_verified_download_from_reader<R: Read>(
|
||||
final_path: &Path,
|
||||
reader: &mut R,
|
||||
expected_sha256: &str,
|
||||
) -> ResultType<()> {
|
||||
let (mut file, temp_path) = create_download_temp_file(final_path)?;
|
||||
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);
|
||||
}
|
||||
drop(file);
|
||||
if let Err(e) = install_verified_download(&temp_path, final_path) {
|
||||
std::fs::remove_file(temp_path).ok();
|
||||
return Err(e);
|
||||
}
|
||||
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<GithubReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubReleaseAsset {
|
||||
name: String,
|
||||
digest: Option<String>,
|
||||
}
|
||||
|
||||
fn fetch_github_asset_sha256(
|
||||
release_or_download_url: &str,
|
||||
download_url: &str,
|
||||
) -> ResultType<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn fetch_github_release_metadata(api_url: &str) -> ResultType<String> {
|
||||
let client = create_http_client_with_url(&api_url, true);
|
||||
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();
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>().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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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 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!(
|
||||
"https://github.com/rustdesk/rustdesk/releases/download/test/rustdesk-cache-test-{}-{}.exe",
|
||||
std::process::id(),
|
||||
hbb_common::rand::random::<u64>()
|
||||
);
|
||||
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::<u64>()
|
||||
);
|
||||
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!(
|
||||
"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_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!(
|
||||
"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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue