diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 85291d637..79b9d85a8 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -9,6 +9,9 @@ on: upload-tag: type: string default: "nightly" + publish-update-metadata: + type: boolean + default: false # NOTE: F-Droid builder script 'flutter/build_fdroid.sh' reads environment # variables from this workflow! @@ -51,10 +54,48 @@ env: ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}" UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}" + PUBLISH_UPDATE_METADATA: "${{ inputs.publish-update-metadata }}" SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" jobs: + validate-update-metadata-keys: + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout source code + if: ${{ inputs.publish-update-metadata }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install Python + if: ${{ inputs.publish-update-metadata }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + if: ${{ inputs.publish-update-metadata }} + run: 'python -m pip install --only-binary=:all: --require-hashes -r res/update/update_metadata_requirements.txt' + + - name: Test update metadata signer + if: ${{ inputs.publish-update-metadata }} + run: | + python -m unittest \ + res/update/test_generate_update_metadata.py \ + res/update/test_update_metadata_cli.py \ + res/update/test_macos_update_scripts.py + + - name: Validate update metadata keys + if: ${{ inputs.publish-update-metadata }} + env: + RUSTDESK_UPDATE_ED25519_SEED: ${{ secrets.RUSTDESK_UPDATE_ED25519_SEED }} + RUSTDESK_UPDATE_ED25519_PUBLIC_KEY: ${{ vars.RUSTDESK_UPDATE_ED25519_PUBLIC_KEY }} + run: python res/update/generate_update_metadata.py check-key + generate-sbom: + needs: [validate-update-metadata-keys] runs-on: ubuntu-latest permissions: @@ -83,9 +124,11 @@ jobs: rustdesk.sbom.json generate-bridge: + needs: [validate-update-metadata-keys] uses: ./.github/workflows/bridge.yml build-RustDeskTempTopMostWindow: + needs: [validate-update-metadata-keys] uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml strategy: fail-fast: false @@ -360,6 +403,18 @@ jobs: pip3 install requests argparse BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/ + - name: Verify Windows Flutter version + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' + shell: powershell + run: | + $reportedVersion = (& ".\rustdesk\rustdesk.exe" --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + throw "rustdesk --version exited with $LASTEXITCODE" + } + if ($reportedVersion -cne $env:VERSION) { + throw "RustDesk reports version '$reportedVersion', expected '$env:VERSION'" + } + - name: Build self-extracted executable shell: bash if: env.UPLOAD_ARTIFACT == 'true' @@ -450,9 +505,25 @@ jobs: run: | BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput + - name: Upload signed Windows Flutter EXE + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.SIGN_BASE_URL != '-2' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-flutter-${{ matrix.job.arch }}-exe + path: ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe + if-no-files-found: error + + - name: Upload signed Windows Flutter MSI + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.SIGN_BASE_URL != '-2' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-flutter-${{ matrix.job.arch }}-msi + path: ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi + if-no-files-found: error + - name: Publish Release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: env.UPLOAD_ARTIFACT == 'true' + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA != 'true' with: prerelease: true tag_name: ${{ env.TAG_NAME }} @@ -463,6 +534,7 @@ jobs: # The fallback for the flutter version, we use Sciter for 32bit Windows. build-for-windows-sciter: name: ${{ matrix.job.target }} (${{ matrix.job.os }}) + needs: [validate-update-metadata-keys] runs-on: ${{ matrix.job.os }} # Temporarily disable this action due to additional test is needed. # if: false @@ -595,6 +667,18 @@ jobs: pip3 install requests argparse BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/ + - name: Verify Windows Sciter version + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' + shell: powershell + run: | + $reportedVersion = (& ".\Release\rustdesk.exe" --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + throw "rustdesk --version exited with $LASTEXITCODE" + } + if ($reportedVersion -cne $env:VERSION) { + throw "RustDesk reports version '$reportedVersion', expected '$env:VERSION'" + } + - name: Build self-extracted executable shell: bash run: | @@ -612,9 +696,17 @@ jobs: run: | BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/ + - name: Upload signed Windows Sciter EXE + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.SIGN_BASE_URL != '-2' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-sciter-x86-exe + path: ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe + if-no-files-found: error + - name: Publish Release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: env.UPLOAD_ARTIFACT == 'true' + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA != 'true' with: prerelease: true tag_name: ${{ env.TAG_NAME }} @@ -755,6 +847,7 @@ jobs: target: x86_64-apple-darwin, os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel extra-build-args: "", + extra-cargo-features: "", arch: x86_64, vcpkg-triplet: x64-osx, } @@ -763,6 +856,7 @@ jobs: os: macos-14, # extra-build-args: "--disable-flutter-texture-render", # disable this for mac, because we see a lot of users reporting flickering both on arm and x64, and we can not confirm if texture rendering has better performance if htere is no vram, https://github.com/rustdesk/rustdesk/issues/6296 extra-build-args: "--screencapturekit", + extra-cargo-features: ",screencapturekit", arch: aarch64, vcpkg-triplet: arm64-osx, } @@ -916,9 +1010,18 @@ jobs: sed -i -e "s/platform :osx, '.*'/platform :osx, '${MIN_MACOS_VERSION}'/" flutter/macos/Podfile sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj + echo "MACOSX_DEPLOYMENT_TARGET=${MIN_MACOS_VERSION}" >> "$GITHUB_ENV" fi ./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }} + - name: Test verified updates + if: env.PUBLISH_UPDATE_METADATA == 'true' + run: | + python3 -m unittest res/update/test_macos_update_scripts.py + cargo test --locked --release --lib platform::macos::update_tests --features "flutter,hwcodec,unix-file-copy-paste${{ matrix.job.extra-cargo-features }}" + cargo test --locked --release --lib verified_dmg_tests --features "flutter,hwcodec,unix-file-copy-paste${{ matrix.job.extra-cargo-features }}" + cargo test --locked --release --lib update_metadata::tests --features "flutter,hwcodec,unix-file-copy-paste${{ matrix.job.extra-cargo-features }}" + - name: create unsigned dmg if: env.UPLOAD_ARTIFACT == 'true' run: | @@ -951,6 +1054,13 @@ jobs: ./flutter/build/macos/Build/Products/Release/RustDesk.app \ ${{ secrets.MACOS_CODESIGN_IDENTITY }} \ ./flutter/macos/Runner/Release.entitlements + if [ "$PUBLISH_UPDATE_METADATA" = "true" ]; then + reported_version="$(./flutter/build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/RustDesk --version)" + if [ "$reported_version" != "$VERSION" ]; then + echo "::error::RustDesk reports version '$reported_version', expected '$VERSION'" + exit 1 + fi + fi create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv # notarize the rustdesk-${{ env.VERSION }}.dmg @@ -958,13 +1068,26 @@ jobs: - name: Rename rustdesk if: env.UPLOAD_ARTIFACT == 'true' + shell: bash + env: + VERSION: ${{ env.VERSION }} + ARCH: ${{ matrix.job.arch }} run: | - for name in rustdesk*??.dmg; do - mv "$name" "${name%%.dmg}-${{ matrix.job.arch }}.dmg" - done + if [ -f "rustdesk-${VERSION}.dmg" ]; then + mv "rustdesk-${VERSION}.dmg" "rustdesk-${VERSION}-${ARCH}.dmg" + fi + test -f "rustdesk-${VERSION}-${ARCH}.dmg" + + - name: Upload signed macOS DMG + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.MACOS_P12_BASE64 != null + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-${{ matrix.job.arch }}-dmg + path: ./rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg + if-no-files-found: error - name: Publish DMG package - if: env.UPLOAD_ARTIFACT == 'true' + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA != 'true' uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 with: prerelease: true @@ -972,6 +1095,141 @@ jobs: files: | rustdesk*-${{ matrix.job.arch }}.dmg + publish-signed-update-metadata: + needs: [build-for-windows-flutter, build-for-windows-sciter, build-for-macOS] + runs-on: ubuntu-22.04 + if: ${{ inputs.upload-artifact && inputs.publish-update-metadata && github.repository == 'rustdesk/rustdesk' }} + concurrency: + group: signed-update-${{ inputs.upload-tag }} + cancel-in-progress: false + permissions: + # Required to inspect and publish signed release assets for the target tag. + contents: write + steps: + - name: Refuse to replace published update metadata + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ env.TAG_NAME }} + run: | + set -euo pipefail + releases="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" + release_id="$( + jq -r --arg tag "$TAG_NAME" ' + [.[][] | select(.tag_name == $tag)] | + if length == 0 then "" + elif length == 1 then .[0].id + else error("multiple releases found for tag") + end + ' <<<"$releases" + )" + if [ -z "$release_id" ]; then + exit 0 + fi + assets="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100")" + if jq -e 'any(.[][]; .name == "rustdesk-update.json")' <<<"$assets" >/dev/null; then + echo "::error::Signed update metadata is already published for ${TAG_NAME}; use a new release tag for corrected artifacts." + exit 1 + fi + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: 'python -m pip install --only-binary=:all: --require-hashes -r res/update/update_metadata_requirements.txt' + + - name: Download Windows artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: windows-* + path: ./artifacts/ + merge-multiple: true + + - name: Download macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: macos-* + path: ./artifacts/ + merge-multiple: true + + - name: Sign update metadata + env: + RUSTDESK_UPDATE_ED25519_SEED: ${{ secrets.RUSTDESK_UPDATE_ED25519_SEED }} + run: | + python res/update/generate_update_metadata.py sign \ + --artifact windows x86_64 exe "./artifacts/rustdesk-${VERSION}-x86_64.exe" \ + --artifact windows x86_64 msi "./artifacts/rustdesk-${VERSION}-x86_64.msi" \ + --artifact windows aarch64 exe "./artifacts/rustdesk-${VERSION}-aarch64.exe" \ + --artifact windows aarch64 msi "./artifacts/rustdesk-${VERSION}-aarch64.msi" \ + --artifact windows x86 exe "./artifacts/rustdesk-${VERSION}-x86-sciter.exe" \ + --artifact macos aarch64 dmg "./artifacts/rustdesk-${VERSION}-aarch64.dmg" \ + --artifact macos x86_64 dmg "./artifacts/rustdesk-${VERSION}-x86_64.dmg" \ + --version "$VERSION" --release-id "$TAG_NAME" \ + --package-id rustdesk \ + --release-base-url https://github.com/rustdesk/rustdesk/releases/download \ + --published-at "$(git log -1 --format=%cI)" \ + --metadata-out ./rustdesk-update.json \ + --signature-out ./rustdesk-update.json.sig + + - name: Verify update metadata + env: + RUSTDESK_UPDATE_ED25519_PUBLIC_KEY: ${{ vars.RUSTDESK_UPDATE_ED25519_PUBLIC_KEY }} + run: | + python res/update/generate_update_metadata.py verify \ + --metadata ./rustdesk-update.json \ + --signature ./rustdesk-update.json.sig \ + --artifact "./artifacts/rustdesk-${VERSION}-x86_64.exe" \ + --artifact "./artifacts/rustdesk-${VERSION}-x86_64.msi" \ + --artifact "./artifacts/rustdesk-${VERSION}-aarch64.exe" \ + --artifact "./artifacts/rustdesk-${VERSION}-aarch64.msi" \ + --artifact "./artifacts/rustdesk-${VERSION}-x86-sciter.exe" \ + --artifact "./artifacts/rustdesk-${VERSION}-aarch64.dmg" \ + --artifact "./artifacts/rustdesk-${VERSION}-x86_64.dmg" \ + --version "$VERSION" --release-id "$TAG_NAME" \ + --package-id rustdesk \ + --release-base-url https://github.com/rustdesk/rustdesk/releases/download + + - name: Upload signed update artifacts + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + fail_on_unmatched_files: true + tag_name: ${{ env.TAG_NAME }} + files: | + ./artifacts/rustdesk-${{ env.VERSION }}-x86_64.exe + ./artifacts/rustdesk-${{ env.VERSION }}-x86_64.msi + ./artifacts/rustdesk-${{ env.VERSION }}-aarch64.exe + ./artifacts/rustdesk-${{ env.VERSION }}-aarch64.msi + ./artifacts/rustdesk-${{ env.VERSION }}-x86-sciter.exe + ./artifacts/rustdesk-${{ env.VERSION }}-aarch64.dmg + ./artifacts/rustdesk-${{ env.VERSION }}-x86_64.dmg + + # The release action replaces same-name assets, so keep published sidecars + # until replacements are ready. + - name: Upload signed update metadata signature + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + fail_on_unmatched_files: true + tag_name: ${{ env.TAG_NAME }} + files: ./rustdesk-update.json.sig + + # Clients discover the release through metadata, so publish it only after every referenced asset. + - name: Publish signed update metadata + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + fail_on_unmatched_files: true + tag_name: ${{ env.TAG_NAME }} + files: ./rustdesk-update.json + publish_unsigned: needs: - build-for-macOS @@ -2172,6 +2430,7 @@ jobs: build-rustdesk-linux-sciter: if: ${{ inputs.upload-artifact }} + needs: [validate-update-metadata-keys] runs-on: ${{ matrix.job.on }} name: build-rustdesk-linux-sciter ${{ matrix.job.target }} strategy: @@ -2552,6 +2811,7 @@ jobs: build-rustdesk-web: if: False + needs: [validate-update-metadata-keys] name: build-rustdesk-web runs-on: ubuntu-22.04 permissions: diff --git a/.github/workflows/flutter-tag.yml b/.github/workflows/flutter-tag.yml index bf39db5cc..620a04c86 100644 --- a/.github/workflows/flutter-tag.yml +++ b/.github/workflows/flutter-tag.yml @@ -15,4 +15,5 @@ jobs: secrets: inherit with: upload-artifact: true - upload-tag: ${{ github.ref_name }} \ No newline at end of file + upload-tag: ${{ github.ref_name }} + publish-update-metadata: ${{ github.repository == 'rustdesk/rustdesk' }} diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index 93f661b7b..959d97ff0 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -7,36 +7,49 @@ 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'; -void handleUpdate(String releasePageUrl) { - _isExtracting.value = false; - String downloadUrl = releasePageUrl.replaceAll('tag', 'download'); - String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); - final String downloadFile = - bind.mainGetCommonSync(key: 'download-file-$version'); - if (downloadFile.startsWith('error:')) { - final error = downloadFile.replaceFirst('error:', ''); - msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error, - releasePageUrl, gFFI.dialogManager); +Future _handleUpdateMe( + String releasePageUrl, Map evt) async { + platformFFI.unregisterEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe); + if (evt.containsKey('error')) { + _showUpdateError(releasePageUrl, evt['error'] as String); + } +} + +Future handleUpdate(String releasePageUrl) async { + final dialogClosed = _showVerifyingUpdate(); + final String? downloadUrl; + try { + downloadUrl = await waitForUpdateVerification( + bind.mainGetCommon(key: 'verified-download-url-$releasePageUrl'), + dialogClosed, + ); + } catch (error) { + _showUpdateError(releasePageUrl, error.toString()); + return; + } + if (downloadUrl == null) { + return; + } + final verifiedDownloadUrl = downloadUrl; + if (downloadUrl.startsWith('error:')) { + _showUpdateError(releasePageUrl, downloadUrl.replaceFirst('error:', '')); return; } - downloadUrl = '$downloadUrl/$downloadFile'; SimpleWrapper downloadId = SimpleWrapper(''); SimpleWrapper onCanceled = SimpleWrapper(() {}); gFFI.dialogManager.dismissAll(); gFFI.dialogManager.show((setState, close, context) { 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, verifiedDownloadUrl, downloadId, onCanceled) + .marginSymmetric(horizontal: 8) + .paddingOnly(top: 12), actions: [ - if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async { + dialogButton('Cancel', onPressed: () async { onCanceled.value(); await bind.mainSetCommon( key: 'cancel-downloader', value: downloadId.value); @@ -56,6 +69,40 @@ void handleUpdate(String releasePageUrl) { }); } +Future waitForUpdateVerification( + Future verification, + Future dialogClosed, +) async { + final dialogClosedMarker = Object(); + final result = await Future.any([ + verification.then((value) => value), + dialogClosed.then((_) => dialogClosedMarker), + ]); + return identical(result, dialogClosedMarker) ? null : result as T; +} + +Future _showVerifyingUpdate() async { + gFFI.dialogManager.dismissAll(); + await gFFI.dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: Text(translate('Preparing for installation ...')), + content: const LinearProgressIndicator(), + actions: [ + dialogButton('Close', onPressed: close, isOutline: true), + ], + onCancel: close, + ), + tag: 'verifying-update', + ); +} + +void _showUpdateError(String releasePageUrl, String error) { + debugPrint('Update error: $error'); + gFFI.dialogManager.dismissAll(); + msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error, + releasePageUrl, gFFI.dialogManager); +} + class UpdateProgress extends StatefulWidget { final String releasePageUrl; final String downloadUrl; @@ -74,9 +121,9 @@ class UpdateProgressState extends State { Timer? _timer; int? _totalSize; int _downloadedSize = 0; + bool _downloadFinished = false; int _getDataFailedCount = 0; final String _eventKeyDownloadNewVersion = 'download-new-version'; - final String _eventKeyExtractUpdateDmg = 'extract-update-dmg'; @override void initState() { @@ -88,11 +135,6 @@ class UpdateProgressState extends State { _eventKeyDownloadNewVersion, handleDownloadNewVersion, replace: true); bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl); - if (isMacOS) { - platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg, - _eventKeyExtractUpdateDmg, handleExtractUpdateDmg, - replace: true); - } } @override @@ -100,10 +142,7 @@ class UpdateProgressState extends State { cancelQueryTimer(); platformFFI.unregisterEventHandler( _eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion); - if (isMacOS) { - platformFFI.unregisterEventHandler( - _eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg); - } + platformFFI.unregisterEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe); super.dispose(); } @@ -128,35 +167,33 @@ class UpdateProgressState extends State { } } - // `isExtractDmg` is true when handling extract-update-dmg event. - // It's a rare case that the dmg file is corrupted and cannot be extracted. - void _onError(String error, {bool isExtractDmg = false}) { + void _onError(String error) { cancelQueryTimer(); - debugPrint( - '${isExtractDmg ? "Extract" : "Download"} new version error: $error'); + debugPrint('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; + final releasePageUrl = widget.releasePageUrl; close() { dialogManager.dismissAll(); } jumplink() { - launchUrl(Uri.parse(widget.releasePageUrl)); + launchUrl(Uri.parse(releasePageUrl)); dialogManager.dismissAll(); } retry() { dialogManager.dismissAll(); - handleUpdate(widget.releasePageUrl); + handleUpdate(releasePageUrl); } final List buttons = [ dialogButton('Download', onPressed: jumplink), - if (!isExtractDmg) dialogButton('Retry', onPressed: retry), + dialogButton('Retry', onPressed: retry), dialogButton('Close', onPressed: close), ]; dialogManager.dismissAll(); @@ -186,6 +223,8 @@ class UpdateProgressState extends State { } } else if (key == 'downloaded_size') { _downloadedSize = value as int; + } else if (key == 'finished') { + _downloadFinished = value as bool; } else if (key == 'error') { if (value != null) { err = value.toString(); @@ -204,7 +243,9 @@ class UpdateProgressState extends State { if (err != '') { _onError(err); } else { - if (_totalSize != null && _downloadedSize >= _totalSize!) { + if (_downloadFinished && + _totalSize != null && + _downloadedSize >= _totalSize!) { cancelQueryTimer(); bind.mainSetCommon( key: 'remove-downloader', value: widget.downloadId.value); @@ -212,13 +253,7 @@ class UpdateProgressState extends State { _onError('The download file size is 0.'); } else { setState(() {}); - if (isMacOS) { - bind.mainSetCommon( - key: 'extract-update-dmg', value: widget.downloadUrl); - _isExtracting.value = true; - } else { - updateMsgBox(); - } + updateMsgBox(); } } else { setState(() {}); @@ -227,6 +262,8 @@ class UpdateProgressState extends State { } void updateMsgBox() { + final releasePageUrl = widget.releasePageUrl; + final downloadUrl = widget.downloadUrl; msgBox( gFFI.sessionId, 'custom-nocancel', @@ -234,30 +271,30 @@ class UpdateProgressState extends State { '{$appName}-to-update-tip', '', gFFI.dialogManager, - onSubmit: () { + onSubmit: () async { debugPrint('Downloaded, update to new version now'); - bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl); + platformFFI.registerEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe, + (evt) => _handleUpdateMe(releasePageUrl, evt), + replace: true); + try { + await bind.mainSetCommon(key: 'update-me', value: downloadUrl); + } catch (error) { + platformFFI.unregisterEventHandler( + _eventKeyUpdateMe, _eventKeyUpdateMe); + _showUpdateError(releasePageUrl, error.toString()); + } }, submitTimeout: 5, ); } - Future handleExtractUpdateDmg(Map evt) async { - _isExtracting.value = false; - if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) { - _onError(evt['err'] as String, isExtractDmg: true); - } else { - updateMsgBox(); - } - } - @override Widget build(BuildContext context) { getValue() => _totalSize == null ? 0.0 : (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!); return LinearProgressIndicator( - value: _isExtracting.isTrue ? null : getValue(), + value: getValue(), minHeight: 20, borderRadius: BorderRadius.circular(5), backgroundColor: Colors.grey[300], diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 165d98349..8ea2f100c 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -20,6 +20,7 @@ windows = { version = "0.61", features = [ "Wdk_System", "Wdk_System_SystemServices", "Win32", + "Win32_Storage_FileSystem", "Win32_System", "Win32_System_SystemInformation", ] } @@ -36,4 +37,3 @@ FileDescription = "RustDesk Remote Desktop" winres = "0.1" winapi = { version = "0.3", features = [ "winnt", "pdh", "synchapi" ] } - diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs index 284dd5ee2..34dff54b7 100644 --- a/libs/portable/src/main.rs +++ b/libs/portable/src/main.rs @@ -19,6 +19,8 @@ const APP_METADATA_CONFIG: &str = "meta.toml"; const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = "; const META_LINE_PREFIX_FILE: &str = "file = "; const APP_PREFIX: &str = "rustdesk"; +#[cfg(windows)] +const VERIFIED_UPDATE_FILE_PREFIX: &str = "rustdesk-verified-"; const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME"; #[cfg(windows)] const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW"; @@ -287,6 +289,13 @@ fn main() -> Result<(), String> { #[cfg(not(windows))] let quick_support = false; + #[cfg(windows)] + if args.first().is_some_and(|arg| arg == "--update") { + if let Err(err) = win::schedule_verified_update_cleanup() { + eprintln!("Failed to schedule verified update file cleanup: {}", err); + } + } + let mut ui = false; let reader = BinaryReader::new()?; if let Some(exe) = setup( @@ -314,6 +323,33 @@ mod win { pub const RUNTIME_BROKER_EXE: &'static str = "C:\\Windows\\System32\\RuntimeBroker.exe"; pub const WIN_TOPMOST_INJECTED_PROCESS_EXE: &'static str = "RuntimeBroker_rustdesk.exe"; + pub(super) fn schedule_verified_update_cleanup() -> std::io::Result<()> { + use windows::{ + core::{HSTRING, PCWSTR}, + Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT}, + }; + + let current_exe = std::env::current_exe()?; + let is_verified_update = current_exe + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with(super::VERIFIED_UPDATE_FILE_PREFIX) && name.ends_with(".exe") + }); + if !is_verified_update { + return Ok(()); + } + + unsafe { + MoveFileExW( + &HSTRING::from(current_exe.as_path()), + PCWSTR::null(), + MOVEFILE_DELAY_UNTIL_REBOOT, + ) + } + .map_err(std::io::Error::other) + } + pub(super) fn copy_runtime_broker(dir: &Path) { let src = RUNTIME_BROKER_EXE; let tgt = WIN_TOPMOST_INJECTED_PROCESS_EXE; diff --git a/res/update/generate_update_metadata.py b/res/update/generate_update_metadata.py new file mode 100644 index 000000000..d3436e9f1 --- /dev/null +++ b/res/update/generate_update_metadata.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import binascii +import hashlib +import json +import os +import re +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from update_metadata_policy import ( + release_artifact_url, + validate_artifact_file_name, + validate_package_id, + validate_published_at, + validate_release, + validate_release_base_url, +) + +APP_NAME = "rustdesk" +SCHEMA_VERSION = 1 +SIGNATURE_ALGORITHM = "ed25519" +SIGNATURE_CONTEXT = b"RustDesk update metadata v1\n" +KEY_ID = "2026-ed25519-main" +SEED_ENV = "RUSTDESK_UPDATE_ED25519_SEED" +PUBLIC_KEY_ENV = "RUSTDESK_UPDATE_ED25519_PUBLIC_KEY" + + +def fail(message): + raise SystemExit(message) + + +def stable_json(value): + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def write_bytes(path, data): + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(data) + + +def sha256_hex(path): + hasher = hashlib.sha256() + with Path(path).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def decode_env(name, expected_size): + encoded = os.environ.get(name) + if not encoded: + fail(f"missing environment variable: {name}") + try: + value = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as error: + fail(f"invalid base64 in {name}: {error}") + if len(value) != expected_size: + fail(f"{name} must decode to {expected_size} bytes") + if base64.b64encode(value).decode("ascii") != encoded: + fail(f"{name} must use canonical base64") + return value + + +def validate_or_fail(function, *args): + try: + return function(*args) + except ValueError as error: + fail(str(error)) + + +def artifact_metadata(spec, release_id, release_base_url): + platform, arch, file_format, raw_path = spec + if not all(value.strip() for value in (platform, arch, file_format)): + fail("artifact selector fields must not be empty") + path = Path(raw_path) + file_name = path.name + validate_or_fail(validate_artifact_file_name, file_name) + size = path.stat().st_size + if size == 0: + fail(f"artifact must not be empty: {file_name}") + return { + "platform": platform, + "arch": arch, + "format": file_format, + "url": release_artifact_url(release_base_url, release_id, file_name), + "file_name": file_name, + "size": size, + "sha256": sha256_hex(path), + } + + +def command_sign(args): + metadata_out = Path(args.metadata_out).resolve() + signature_out = Path(args.signature_out).resolve() + if metadata_out == signature_out: + fail("metadata and signature outputs must be different files") + validate_or_fail(validate_release, args.release_id, args.version) + validate_or_fail(validate_published_at, args.published_at) + package_id = validate_or_fail(validate_package_id, args.package_id) + release_base_url = validate_or_fail( + validate_release_base_url, args.release_base_url + ) + artifacts = [ + artifact_metadata(spec, args.release_id, release_base_url) + for spec in args.artifact + ] + selectors = [(item["platform"], item["arch"], item["format"]) for item in artifacts] + names = [item["file_name"] for item in artifacts] + if len(set(selectors)) != len(selectors): + fail("duplicate artifact selector") + if len(set(names)) != len(names): + fail("duplicate artifact file name") + artifacts.sort(key=lambda item: (item["platform"], item["arch"], item["format"])) + metadata = { + "schema_version": SCHEMA_VERSION, + "app": APP_NAME, + "package_id": package_id, + "version": args.version, + "release_id": args.release_id, + "published_at": args.published_at, + "signature_key_id": KEY_ID, + "artifacts": artifacts, + } + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(decode_env(SEED_ENV, 32)) + metadata_bytes = stable_json(metadata) + signature = { + "schema_version": SCHEMA_VERSION, + "algorithm": SIGNATURE_ALGORITHM, + "key_id": KEY_ID, + "signature": base64.b64encode( + private_key.sign(SIGNATURE_CONTEXT + metadata_bytes) + ).decode("ascii"), + } + write_bytes(metadata_out, metadata_bytes) + write_bytes(signature_out, stable_json(signature)) + + +def verify_metadata_signature(metadata_bytes, signature): + if ( + signature.get("schema_version") != SCHEMA_VERSION + or signature.get("algorithm") != SIGNATURE_ALGORITHM + or signature.get("key_id") != KEY_ID + ): + fail("invalid signature metadata") + public_key = ed25519.Ed25519PublicKey.from_public_bytes(decode_env(PUBLIC_KEY_ENV, 32)) + try: + public_key.verify( + base64.b64decode(signature["signature"], validate=True), + SIGNATURE_CONTEXT + metadata_bytes, + ) + except (InvalidSignature, ValueError, binascii.Error): + fail("invalid metadata signature") + + +def validated_artifacts(metadata, local_artifacts): + artifacts = metadata.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + fail("metadata must contain artifacts") + selector_fields = ("platform", "arch", "format") + required_fields = selector_fields + ("file_name", "url", "size", "sha256") + if any( + not isinstance(artifact, dict) + or any(field not in artifact for field in required_fields) + for artifact in artifacts + ): + fail("artifact metadata is missing required fields") + names = [artifact.get("file_name") for artifact in artifacts] + for file_name in names: + validate_or_fail(validate_artifact_file_name, file_name) + selectors = [ + tuple(artifact[field] for field in selector_fields) + for artifact in artifacts + ] + if any( + not all(isinstance(value, str) and value.strip() for value in selector) + for selector in selectors + ): + fail("artifact selector fields must be non-empty strings") + if len(set(names)) != len(names) or set(names) != set(local_artifacts): + fail("artifact file set mismatch") + if len(set(selectors)) != len(selectors): + fail("duplicate artifact selector") + return artifacts + + +def command_verify(args): + metadata_bytes = Path(args.metadata).read_bytes() + metadata = json.loads(metadata_bytes) + signature = json.loads(Path(args.signature).read_bytes()) + verify_metadata_signature(metadata_bytes, signature) + package_id = validate_or_fail(validate_package_id, args.package_id) + release_base_url = validate_or_fail( + validate_release_base_url, args.release_base_url + ) + expected_fields = { + "schema_version": SCHEMA_VERSION, + "app": APP_NAME, + "package_id": package_id, + "version": args.version, + "release_id": args.release_id, + "signature_key_id": KEY_ID, + } + if any(metadata.get(key) != value for key, value in expected_fields.items()): + fail("metadata does not match the release") + validate_or_fail(validate_release, args.release_id, args.version) + validate_or_fail(validate_published_at, metadata.get("published_at", "")) + local_paths = [Path(path) for path in args.artifact] + local_artifacts = {path.name: path for path in local_paths} + if len(local_artifacts) != len(local_paths): + fail("duplicate local artifact file name") + artifacts = validated_artifacts(metadata, local_artifacts) + for artifact in artifacts: + file_name = artifact["file_name"] + expected_url = release_artifact_url( + release_base_url, args.release_id, file_name + ) + if artifact["url"] != expected_url: + fail(f"artifact URL mismatch for {file_name}") + path = local_artifacts[file_name] + if path.stat().st_size != artifact["size"]: + fail(f"artifact size mismatch for {file_name}") + if sha256_hex(path) != artifact["sha256"]: + fail(f"artifact sha256 mismatch for {file_name}") + + +def command_check_key(args): + seed = decode_env(SEED_ENV, 32) + configured_key = decode_env(PUBLIC_KEY_ENV, 32) + derived_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed).public_key() + derived_key_bytes = derived_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + if derived_key_bytes != configured_key: + fail(f"{SEED_ENV} does not match {PUBLIC_KEY_ENV}") + + source = Path(args.rust_source).read_text(encoding="utf-8") + match = re.search( + rf'TrustedUpdateKey\s*\{{[^{{}}]*key_id:\s*"{KEY_ID}"[^{{}}]*public_key:\s*\[([^\]]+)\]', + source, + re.S, + ) + if not match: + fail("failed to find embedded update public key") + try: + embedded_key = bytes( + int(part) for part in match.group(1).split(",") if part.strip() + ) + except ValueError as error: + fail(f"invalid embedded update public key: {error}") + if embedded_key != configured_key: + fail(f"embedded update public key does not match {PUBLIC_KEY_ENV}") + + +def build_parser(): + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + sign = commands.add_parser("sign") + sign.add_argument("--artifact", action="append", nargs=4, required=True) + sign.add_argument("--version", required=True) + sign.add_argument("--release-id", required=True) + sign.add_argument("--package-id", required=True) + sign.add_argument("--release-base-url", required=True) + sign.add_argument("--published-at", required=True) + sign.add_argument("--metadata-out", required=True) + sign.add_argument("--signature-out", required=True) + sign.set_defaults(func=command_sign) + verify = commands.add_parser("verify") + verify.add_argument("--metadata", required=True) + verify.add_argument("--signature", required=True) + verify.add_argument("--artifact", action="append", required=True) + verify.add_argument("--version", required=True) + verify.add_argument("--release-id", required=True) + verify.add_argument("--package-id", required=True) + verify.add_argument("--release-base-url", required=True) + verify.set_defaults(func=command_verify) + check_key = commands.add_parser("check-key") + check_key.add_argument( + "--rust-source", default="src/update_metadata.rs" + ) + check_key.set_defaults(func=command_check_key) + return parser + + +def main(): + args = build_parser().parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/res/update/test_generate_update_metadata.py b/res/update/test_generate_update_metadata.py new file mode 100644 index 000000000..e632deefd --- /dev/null +++ b/res/update/test_generate_update_metadata.py @@ -0,0 +1,300 @@ +import base64 +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +SCRIPT = Path(__file__).with_name("generate_update_metadata.py") +OFFICIAL_RELEASE_BASE_URL = "https://github.com/rustdesk/rustdesk/releases/download" + + +class GenerateUpdateMetadataTest(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + private_key = ed25519.Ed25519PrivateKey.generate() + seed = private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + self.seed = base64.b64encode(seed).decode("ascii") + self.public_key = base64.b64encode(public_key).decode("ascii") + + def tearDown(self): + self.temp_dir.cleanup() + + def run_script(self, *args, seed=None, public_key=None): + env = os.environ.copy() + env.pop("RUSTDESK_UPDATE_ED25519_SEED", None) + env.pop("RUSTDESK_UPDATE_ED25519_PUBLIC_KEY", None) + if seed is not None: + env["RUSTDESK_UPDATE_ED25519_SEED"] = seed + if public_key is not None: + env["RUSTDESK_UPDATE_ED25519_PUBLIC_KEY"] = public_key + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + cwd=SCRIPT.parents[2], + env=env, + text=True, + capture_output=True, + ) + + def artifact(self, name="rustdesk-1.4.6-x86_64.exe", data=b"rustdesk"): + path = self.root / name + path.write_bytes(data) + return path + + def sign( + self, + artifacts, + *, + version="1.4.6", + release_id="v1.4.6", + package_id="rustdesk", + release_base_url=OFFICIAL_RELEASE_BASE_URL, + seed=None, + ): + metadata = self.root / "rustdesk-update.json" + signature = self.root / "rustdesk-update.json.sig" + args = ["sign"] + for platform, arch, file_format, path in artifacts: + args.extend(["--artifact", platform, arch, file_format, str(path)]) + args.extend( + [ + "--version", + version, + "--release-id", + release_id, + "--package-id", + package_id, + "--release-base-url", + release_base_url, + "--published-at", + "2026-05-14T00:00:00Z", + "--metadata-out", + str(metadata), + "--signature-out", + str(signature), + ] + ) + result = self.run_script(*args, seed=self.seed if seed is None else seed) + return metadata, signature, result + + def verify( + self, + metadata, + signature, + artifacts, + *, + public_key=None, + package_id="rustdesk", + release_base_url=OFFICIAL_RELEASE_BASE_URL, + ): + args = [ + "verify", + "--metadata", + str(metadata), + "--signature", + str(signature), + "--version", + "1.4.6", + "--release-id", + "v1.4.6", + "--package-id", + package_id, + "--release-base-url", + release_base_url, + ] + for artifact in artifacts: + args.extend(["--artifact", str(artifact)]) + return self.run_script( + *args, + public_key=public_key or self.public_key, + ) + + def rust_source(self, public_key=None): + key_bytes = base64.b64decode(public_key or self.public_key) + source = self.root / "update_metadata.rs" + source.write_text( + 'TrustedUpdateKey { key_id: "2026-ed25519-main", public_key: [' + + ",".join(str(byte) for byte in key_bytes) + + "] }", + encoding="utf-8", + ) + return source + + def test_signs_and_verifies_release_artifacts(self): + exe = self.artifact() + dmg = self.artifact("rustdesk-1.4.6-aarch64.dmg", b"dmg") + specs = [("windows", "x86_64", "exe", exe), ("macos", "aarch64", "dmg", dmg)] + + metadata, signature, signed = self.sign(specs) + verified = self.verify(metadata, signature, [exe, dmg]) + + self.assertEqual(signed.returncode, 0, signed.stderr) + self.assertEqual(verified.returncode, 0, verified.stderr) + data = json.loads(metadata.read_text(encoding="utf-8")) + self.assertEqual(data["release_id"], "v1.4.6") + self.assertEqual({item["file_name"] for item in data["artifacts"]}, {exe.name, dmg.name}) + + def test_verification_rejects_tampering(self): + artifact = self.artifact() + metadata, signature, _ = self.sign([("windows", "x86_64", "exe", artifact)]) + artifact.write_bytes(b"tampered") + self.assertNotEqual(self.verify(metadata, signature, [artifact]).returncode, 0) + + artifact.write_bytes(b"rustdesk") + data = json.loads(metadata.read_text(encoding="utf-8")) + data["published_at"] = "2026-05-15T00:00:00Z" + metadata.write_text(json.dumps(data), encoding="utf-8") + self.assertNotEqual(self.verify(metadata, signature, [artifact]).returncode, 0) + + def test_verification_rejects_wrong_public_key(self): + artifact = self.artifact() + metadata, signature, _ = self.sign([("windows", "x86_64", "exe", artifact)]) + wrong_key = base64.b64encode(b"x" * 32).decode("ascii") + self.assertNotEqual( + self.verify( + metadata, + signature, + [artifact], + public_key=wrong_key, + ).returncode, + 0, + ) + + def test_sign_rejects_invalid_release_inputs(self): + artifact = self.artifact() + spec = [("windows", "x86_64", "exe", artifact)] + self.assertNotEqual(self.sign(spec, version="1.4.7")[2].returncode, 0) + for release_id in ("bad/tag", "bad\tid", "bad%zz"): + self.assertNotEqual(self.sign(spec, release_id=release_id)[2].returncode, 0) + self.assertNotEqual(self.sign(spec, seed="invalid")[2].returncode, 0) + duplicate = spec + [("windows", "x86_64", "exe", artifact)] + self.assertNotEqual(self.sign(duplicate)[2].returncode, 0) + + def test_signs_and_verifies_custom_release_identity(self): + package_id = "com.example.rustdesk-custom" + release_base_url = "https://updates.example.com/releases/download" + artifact = self.artifact("rustdesk-custom-1.4.6-aarch64.dmg", b"dmg") + + metadata, signature, signed = self.sign( + [("macos", "aarch64", "dmg", artifact)], + package_id=package_id, + release_base_url=release_base_url, + ) + verified = self.verify( + metadata, + signature, + [artifact], + package_id=package_id, + release_base_url=release_base_url, + ) + + self.assertEqual(signed.returncode, 0, signed.stderr) + self.assertEqual(verified.returncode, 0, verified.stderr) + data = json.loads(metadata.read_text(encoding="utf-8")) + self.assertEqual(data["package_id"], package_id) + self.assertEqual( + data["artifacts"][0]["url"], + f"{release_base_url}/v1.4.6/{artifact.name}", + ) + + def test_rejects_unsafe_custom_release_policy(self): + artifact = self.artifact("rustdesk-custom-1.4.6-aarch64.dmg", b"dmg") + spec = [("macos", "aarch64", "dmg", artifact)] + + for package_id in ("", "com.example/custom", "com.example custom"): + with self.subTest(package_id=package_id): + self.assertNotEqual( + self.sign(spec, package_id=package_id)[2].returncode, + 0, + ) + for release_base_url in ( + "http://updates.example.com/releases", + "https://user@updates.example.com/releases", + "https://updates.example.com/releases/", + "https://updates.example.com/releases?channel=stable", + "https://updates.example.com/relea\nses", + ): + with self.subTest(release_base_url=release_base_url): + self.assertNotEqual( + self.sign(spec, release_base_url=release_base_url)[2].returncode, + 0, + ) + + def test_checks_embedded_public_key(self): + source = self.rust_source() + result = self.run_script( + "check-key", + "--rust-source", + str(source), + seed=self.seed, + public_key=self.public_key, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_check_key_rejects_invalid_or_mismatched_seed(self): + source = self.rust_source() + invalid_seeds = [ + None, + "invalid", + base64.b64encode(b"x" * 31).decode("ascii"), + base64.b64encode(b"x" * 32).decode("ascii"), + ] + + for seed in invalid_seeds: + with self.subTest(seed=seed): + result = self.run_script( + "check-key", + "--rust-source", + str(source), + seed=seed, + public_key=self.public_key, + ) + self.assertNotEqual(result.returncode, 0) + + def test_check_key_rejects_invalid_public_key(self): + source = self.rust_source() + invalid_keys = [ + None, + "invalid", + base64.b64encode(b"x" * 31).decode("ascii"), + ] + + for public_key in invalid_keys: + with self.subTest(public_key=public_key): + result = self.run_script( + "check-key", + "--rust-source", + str(source), + seed=self.seed, + public_key=public_key, + ) + self.assertNotEqual(result.returncode, 0) + + def test_check_key_rejects_mismatched_embedded_key(self): + embedded_key = base64.b64encode(b"x" * 32).decode("ascii") + result = self.run_script( + "check-key", + "--rust-source", + str(self.rust_source(embedded_key)), + seed=self.seed, + public_key=self.public_key, + ) + + self.assertNotEqual(result.returncode, 0) + +if __name__ == "__main__": + unittest.main() diff --git a/res/update/test_macos_update_scripts.py b/res/update/test_macos_update_scripts.py new file mode 100644 index 000000000..dce09e14d --- /dev/null +++ b/res/update/test_macos_update_scripts.py @@ -0,0 +1,104 @@ +import plistlib +import socket +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +MACOS_SOURCE = (REPOSITORY_ROOT / "src/platform/macos.rs").read_text( + encoding="utf-8" +) +DAEMON_SCRIPT = ( + REPOSITORY_ROOT / "src/platform/privileges_scripts/update.scpt" +).read_text(encoding="utf-8") +MANUAL_SCRIPT = MACOS_SOURCE.split( + 'const PRIVILEGED_UPDATE_BODY: &str = r#"', 1 +)[1].split('"#;', 1)[0] + + +def write_bundle_id(app, bundle_id): + if bundle_id is None: + return + info = app / "Contents/Info.plist" + info.parent.mkdir(parents=True) + info.write_bytes(plistlib.dumps({"CFBundleIdentifier": bundle_id})) + + +class MacosUpdateScriptTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == "darwin", "requires macOS AppleScript") + def test_daemon_update_rejects_stale_ipc_sockets(self): + with tempfile.TemporaryDirectory(dir="/tmp") as directory: + for check in ("check_service", "check_agent"): + with self.subTest(check=check), socket.socket( + socket.AF_UNIX, socket.SOCK_STREAM + ) as listener: + path = str(Path(directory) / check) + listener.bind(path) + listener.listen(1) + script = DAEMON_SCRIPT.split(" set sh to", 1)[0] + script += f" return {check}\nend run\n" + script = script.replace("/tmp/RustDesk-service/ipc_service", path) + script = script.replace("/tmp/RustDesk-$uid/ipc", path) + command = subprocess.check_output( + ["osascript", "-e", script, "test", "0", "", ""], + text=True, + timeout=10, + ) + command = "launchctl() { echo 'state = running'; }; " + command + args = ["/bin/sh", "-c", command] + self.assertEqual(subprocess.call(args, timeout=10), 0) + listener.setblocking(False) + with self.assertRaises(BlockingIOError): + connection, _ = listener.accept() + connection.close() + listener.close() + self.assertNotEqual(subprocess.call(args, timeout=10), 0) + + @unittest.skipUnless(sys.platform == "darwin", "requires macOS AppleScript") + def test_manual_update_requires_matching_bundle_identity(self): + validation = next( + line for line in MANUAL_SCRIPT.splitlines() + if "set validate_verified_app" in line + ) + script = "\n".join([ + "on run {app_bundle, expected_bundle_id}", + "set app_bundle_q to quoted form of app_bundle", + 'set installed_info_q to quoted form of (app_bundle & "/Contents/Info.plist")', + "set expected_bundle_id_q to quoted form of expected_bundle_id", + validation, + "return validate_verified_app", + "end run", + ]) + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + bundle_id = "com.carriez.rustdesk" + cases = ( + ("new", None, bundle_id, 0), + ("installed", bundle_id, bundle_id, 0), + ("wrong_candidate", None, "example.other", 1), + ("wrong_destination", "example.other", bundle_id, 1), + ) + for name, installed_id, candidate_id, exit_code in cases: + with self.subTest(case=name): + installed = root / name / "installed.app" + candidate = root / name / "candidate.app" + write_bundle_id(installed, installed_id) + write_bundle_id(candidate, candidate_id) + command = subprocess.check_output( + ["osascript", "-e", script, str(installed), bundle_id], + text=True, timeout=10, + ) + result = subprocess.run( + ["/bin/sh", "-c", 'set -e; verified_app="$1"; ' + command, + "update-test", str(candidate)], + text=True, capture_output=True, timeout=10, + ) + self.assertEqual(result.returncode, exit_code, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/res/update/test_update_metadata_cli.py b/res/update/test_update_metadata_cli.py new file mode 100644 index 000000000..b55fa5250 --- /dev/null +++ b/res/update/test_update_metadata_cli.py @@ -0,0 +1,74 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("generate_update_metadata.py") + + +class UpdateMetadataCliTests(unittest.TestCase): + def test_sign_and_verify_require_update_identity_policy(self): + commands = ( + [ + "sign", + "--artifact", "macos", "aarch64", "dmg", "missing.dmg", + "--version", "1.4.6", + "--release-id", "v1.4.6", + "--published-at", "invalid", + "--metadata-out", "metadata.json", + "--signature-out", "signature.json", + ], + [ + "verify", + "--metadata", "missing.json", + "--signature", "missing.sig", + "--artifact", "missing.dmg", + "--version", "1.4.6", + "--release-id", "v1.4.6", + ], + ) + + for command in commands: + with self.subTest(command=command[0]): + result = subprocess.run( + [sys.executable, str(SCRIPT), *command], + text=True, + capture_output=True, + ) + self.assertIn("the following arguments are required", result.stderr) + self.assertIn("--package-id", result.stderr) + self.assertIn("--release-base-url", result.stderr) + + def test_sign_rejects_empty_artifact(self): + with tempfile.TemporaryDirectory() as directory: + artifact = Path(directory) / "rustdesk-1.4.6-aarch64.dmg" + artifact.touch() + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "sign", + "--artifact", "macos", "aarch64", "dmg", str(artifact), + "--version", "1.4.6", + "--release-id", "v1.4.6", + "--package-id", "rustdesk", + "--release-base-url", + "https://github.com/rustdesk/rustdesk/releases/download", + "--published-at", "2026-05-14T00:00:00Z", + "--metadata-out", str(Path(directory) / "metadata.json"), + "--signature-out", str(Path(directory) / "metadata.json.sig"), + ], + env=os.environ.copy(), + text=True, + capture_output=True, + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("artifact must not be empty", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/res/update/update_metadata_policy.py b/res/update/update_metadata_policy.py new file mode 100644 index 000000000..23c306a3d --- /dev/null +++ b/res/update/update_metadata_policy.py @@ -0,0 +1,100 @@ +import re +from datetime import datetime +from string import ascii_letters, digits +from urllib.parse import urlsplit + +PACKAGE_ID_CHARACTERS = frozenset(ascii_letters + digits + ".-") +RFC3339_TIMESTAMP = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})" +) +ARTIFACT_FILE_NAME_PATTERN = re.compile(r"[A-Za-z0-9._-]+") + + +def validate_release(release_id, version): + if ( + not release_id + or not release_id.isascii() + or release_id in {".", ".."} + or any( + char.isspace() or not char.isprintable() or char in "/\\?%#" + for char in release_id + ) + ): + raise ValueError(f"invalid release id: {release_id}") + match = re.fullmatch(r"v?([0-9]+)\.([0-9]+)\.([0-9]+)", release_id) + if not match: + return + segments = [int(part) for part in match.groups()] + if any(part > 65535 for part in segments) or not any(segments): + raise ValueError(f"invalid release version: {release_id}") + display_version = ".".join(match.groups()) + if display_version != version: + raise ValueError( + f"release id {release_id} maps to {display_version}, not {version}" + ) + + +def validate_published_at(value): + if not RFC3339_TIMESTAMP.fullmatch(value): + raise ValueError("published_at must be an RFC 3339 timestamp") + normalized = value[:-1] + "+00:00" if value[-1] in "Zz" else value + try: + datetime.fromisoformat(normalized) + except ValueError as error: + raise ValueError( + "published_at must be an RFC 3339 timestamp" + ) from error + + +def validate_artifact_file_name(file_name): + if ( + not isinstance(file_name, str) + or file_name in {".", ".."} + or not ARTIFACT_FILE_NAME_PATTERN.fullmatch(file_name) + ): + raise ValueError(f"invalid artifact file name: {file_name}") + + +def validate_package_id(value): + if ( + not isinstance(value, str) + or not value + or not value.isascii() + or any(char not in PACKAGE_ID_CHARACTERS for char in value) + or not any(char.isalnum() for char in value) + ): + raise ValueError(f"invalid package id: {value}") + return value + + +def validate_release_base_url(value): + if ( + not isinstance(value, str) + or not value + or not value.isascii() + or any(char.isspace() or not char.isprintable() for char in value) + or value.endswith("/") + or "\\" in value + or "?" in value + or "#" in value + ): + raise ValueError(f"invalid release base URL: {value}") + try: + parsed = urlsplit(value) + parsed.port + except ValueError as error: + raise ValueError(f"invalid release base URL: {value}") from error + if ( + parsed.scheme != "https" + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + ): + raise ValueError( + "release base URL must be HTTPS and must not contain credentials" + ) + return value + + +def release_artifact_url(release_base_url, release_id, file_name): + return f"{release_base_url}/{release_id}/{file_name}" diff --git a/res/update/update_metadata_requirements.txt b/res/update/update_metadata_requirements.txt new file mode 100644 index 000000000..1f07aa535 --- /dev/null +++ b/res/update/update_metadata_requirements.txt @@ -0,0 +1,7 @@ +# Locked for the CPython 3.12 Linux x86_64 release-signing runner. +cryptography==50.0.0 \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f +cffi==2.1.0 \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 +pycparser==3.0 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 diff --git a/src/common.rs b/src/common.rs index 3e761322a..fa5c20334 100644 --- a/src/common.rs +++ b/src/common.rs @@ -37,7 +37,7 @@ use hbb_common::{ use crate::{ hbbs_http::{create_http_client_async, get_url_for_tls}, - ui_interface::{get_api_server as ui_get_api_server, get_option, is_installed, set_option}, + ui_interface::{get_api_server as ui_get_api_server, get_option, set_option}, }; #[derive(Debug, Eq, PartialEq)] @@ -1020,39 +1020,94 @@ pub fn check_software_update() { } } -// No need to check `danger_accept_invalid_cert` for now. -// Because the url is always `https://api.rustdesk.com/version/latest`. -#[tokio::main(flavor = "current_thread")] -pub async fn do_check_software_update() -> hbb_common::ResultType<()> { - let (request, url) = - hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); - 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_not_cached = tls_type.is_none(); - let tls_type = tls_type.unwrap_or(TlsType::Rustls); - let client = create_http_client_async(tls_type, false); - let latest_release_response = match client.post(&url).json(&request).send().await { - Ok(resp) => { - upsert_tls_cache(tls_url, tls_type, false); - resp - } - Err(err) => { - if is_tls_not_cached && err.is_request() { - let tls_type = TlsType::NativeTls; - let client = create_http_client_async(tls_type, false); - let resp = client.post(&url).json(&request).send().await?; - upsert_tls_cache(tls_url, tls_type, false); - resp - } else { - return Err(err.into()); - } - } +pub(crate) fn release_id_from_update_url(update_url: &str) -> ResultType { + let url = url::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 + ); + } + if url_has_explicit_port(update_url) + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + bail!( + "Update URL must not contain credentials or an explicit port: {}", + update_url + ); + } + if url.query().is_some() || url.fragment().is_some() { + bail!( + "Update URL must not contain query or fragment: {}", + update_url + ); + } + + let Some(segments) = url.path_segments() else { + bail!("Update URL has no path: {}", update_url); }; - let bytes = latest_release_response.bytes().await?; + let segments = segments.collect::>(); + let release_id = match segments.as_slice() { + ["rustdesk", "rustdesk", "releases", "tag", release_id] => *release_id, + ["rustdesk", "rustdesk", "releases", "tag", release_id, ""] => *release_id, + _ => bail!( + "Update URL is not a RustDesk release tag URL: {}", + update_url + ), + }; + if release_id.is_empty() + || release_id == "." + || release_id == ".." + || release_id.contains([' ', '/', '\\', '?', '#']) + || release_id.contains('%') + { + bail!("Update URL has no release id: {}", update_url); + } + Ok(release_id.to_owned()) +} + +pub(crate) fn url_has_explicit_port(raw_url: &str) -> bool { + let Some((_, rest)) = raw_url.split_once("://") else { + return false; + }; + let authority = rest + .split(|c| matches!(c, '/' | '?' | '#')) + .next() + .unwrap_or_default(); + let host_port = authority.rsplit('@').next().unwrap_or(authority); + host_port.rsplit_once(':').is_some() +} + +pub(crate) fn display_version_from_release_id(release_id: &str) -> ResultType { + let display_version = release_id.strip_prefix('v').unwrap_or(release_id); + let segments = display_version.split('.').collect::>(); + if segments.len() != 3 + || segments.iter().any(|segment| { + segment.is_empty() + || !segment.chars().all(|c| c.is_ascii_digit()) + || segment.parse::().is_err() + }) + { + bail!("Release id is not a stable display version: {}", release_id); + } + if get_version_number(display_version) <= 0 { + bail!("Release id has invalid display version: {}", release_id); + } + Ok(display_version.to_owned()) +} + +fn clear_software_update_url() { + let mut update_url = SOFTWARE_UPDATE_URL.lock().unwrap(); + *update_url = String::new(); +} + +fn process_software_update_check_response(bytes: Bytes) -> ResultType<()> { let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; let response_url = resp.url; - let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); + let release_id = release_id_from_update_url(&response_url)?; + let latest_release_version = display_version_from_release_id(&release_id)?; if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) { #[cfg(feature = "flutter")] @@ -1066,11 +1121,60 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> { } *SOFTWARE_UPDATE_URL.lock().unwrap() = response_url; } else { - *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); + clear_software_update_url(); } Ok(()) } +// No need to check `danger_accept_invalid_cert` for now. +// Because the url is always `https://api.rustdesk.com/version/latest`. +#[tokio::main(flavor = "current_thread")] +pub async fn do_check_software_update() -> hbb_common::ResultType<()> { + let (request, url) = + hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); + 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_not_cached = tls_type.is_none(); + let tls_type = tls_type.unwrap_or(TlsType::Rustls); + let client = create_http_client_async(tls_type, false); + let latest_release_response: ResultType<_> = match client.post(&url).json(&request).send().await + { + Ok(resp) => Ok((tls_type, resp)), + Err(err) => { + if is_tls_not_cached && err.is_request() { + let tls_type = TlsType::NativeTls; + let client = create_http_client_async(tls_type, false); + match client.post(&url).json(&request).send().await { + Ok(resp) => Ok((tls_type, resp)), + Err(err) => Err(err.into()), + } + } else { + Err(err.into()) + } + } + }; + let response_bytes = match latest_release_response { + Ok((used_tls_type, response)) => { + upsert_tls_cache(tls_url, used_tls_type, false); + let status = response.status(); + if !status.is_success() { + Err(anyhow!( + "Software update check failed with HTTP status: {status}" + )) + } else { + response.bytes().await.map_err(|err| err.into()) + } + } + Err(err) => Err(err), + }; + let result = response_bytes.and_then(process_software_update_check_response); + if result.is_err() { + clear_software_update_url(); + } + result +} + #[inline] pub fn get_app_name() -> String { hbb_common::config::APP_NAME.read().unwrap().clone() @@ -2947,6 +3051,42 @@ mod tests { }; use std::collections::HashSet; + #[test] + fn parses_stable_rustdesk_release_url() { + let update_url = "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6"; + + assert_eq!(release_id_from_update_url(update_url).unwrap(), "v1.4.6"); + assert_eq!(display_version_from_release_id("v1.4.6").unwrap(), "1.4.6"); + assert_eq!( + release_id_from_update_url(&format!("{update_url}/")).unwrap(), + "v1.4.6" + ); + } + + #[test] + fn rejects_untrusted_release_urls_and_unstable_versions() { + for update_url in [ + "http://github.com/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://github.com:8443/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://github.com:443/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://user@github.com/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://example.com/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://github.com/other/rustdesk/releases/tag/v1.4.6", + "https://github.com/rustdesk/other/releases/tag/v1.4.6", + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6?x=1", + "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6#asset", + "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6/extra", + "https://github.com/rustdesk/rustdesk/releases/tag/v1%2F4%2F6", + "https://github.com/rustdesk/rustdesk/releases/tag/%2e%2e", + ] { + assert!(release_id_from_update_url(update_url).is_err()); + } + for release_id in ["v1.4.6-rc1", "nightly", "v9223372036854775807.1.1"] { + assert!(display_version_from_release_id(release_id).is_err()); + } + } + #[test] fn a_cursor_content_id_fits_a_web_client_number() { const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; diff --git a/src/core_main.rs b/src/core_main.rs index b5a451ed8..424f2c6ef 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -1,4 +1,4 @@ -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(target_os = "windows")] use crate::client::translate; #[cfg(not(debug_assertions))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -23,13 +23,26 @@ macro_rules! my_println{ }; } +pub enum CoreMainAction { + StartUi(Vec), + ExitFailure(hbb_common::anyhow::Error), +} + +#[cfg(target_os = "macos")] +fn finish_macos_update(result: hbb_common::ResultType<()>) -> Option { + match result { + Ok(()) => None, + Err(err) => Some(CoreMainAction::ExitFailure(err)), + } +} + /// shared by flutter and sciter main function /// /// [Note] /// If it returns [`None`], then the process will terminate, and flutter gui will not be started. -/// If it returns [`Some`], then the process will continue, and flutter gui will be started. +/// If it returns [`Some`], the action determines whether to start the UI or report failure. #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn core_main() -> Option> { +pub fn core_main() -> Option { if !crate::common::global_init() { return None; } @@ -118,7 +131,7 @@ pub fn core_main() -> Option> { } #[cfg(feature = "flutter")] if _is_flutter_invoke_new_connection { - return core_main_invoke_new_connection(std::env::args()); + return core_main_invoke_new_connection(std::env::args()).map(CoreMainAction::StartUi); } let click_setup = cfg!(windows) && args.is_empty() && crate::common::is_setup(&arg_exe); if click_setup && !config::is_disable_installation() { @@ -171,7 +184,7 @@ pub fn core_main() -> Option> { // linux uni (url) go here. #[cfg(all(target_os = "linux", feature = "flutter"))] if args.len() > 0 && args[0].starts_with(&crate::get_uri_prefix()) { - return try_send_by_dbus(args[0].clone()); + return try_send_by_dbus(args[0].clone()).map(CoreMainAction::StartUi); } #[cfg(windows)] @@ -227,6 +240,10 @@ pub fn core_main() -> Option> { } return None; } else if args[0] == "--update" { + // The elevated updater cannot delete its running verified copy. + if let Err(err) = platform::schedule_current_verified_update_file_cleanup() { + log::warn!("Failed to schedule verified update file cleanup: {}", err); + } if config::is_disable_installation() { return None; } @@ -336,38 +353,8 @@ pub fn core_main() -> Option> { } #[cfg(target_os = "macos")] { - use crate::platform; if args[0] == "--update" { - if args.len() > 1 && args[1].ends_with(".dmg") { - // Version check is unnecessary unless downgrading to an older version - // that lacks "update dmg" support. This is a special case since we cannot - // detect the version before extracting the DMG, so we skip the check. - let dmg_path = &args[1]; - println!("Updating from DMG: {}", dmg_path); - match platform::update_from_dmg(dmg_path) { - Ok(_) => { - println!("Update process from DMG started successfully."); - // The new process will handle the rest. We can exit. - } - Err(err) => { - eprintln!("Failed to start update from DMG: {}", err); - } - } - } else { - println!("Starting update process..."); - log::info!("Starting update process..."); - let _text = match platform::update_me() { - Ok(_) => { - println!("{}", translate("Updated successfully!".to_string())); - log::info!("Updated successfully!"); - } - Err(err) => { - eprintln!("Update failed with error: {}", err); - log::error!("Update failed with error: {err}"); - } - }; - } - return None; + return finish_macos_update(crate::manual_update::execute_macos_update(&args)); } } if args[0] == "--remove" { @@ -731,9 +718,9 @@ pub fn core_main() -> Option> { } //_async_logger_holder.map(|x| x.flush()); #[cfg(feature = "flutter")] - return Some(flutter_args); + return Some(CoreMainAction::StartUi(flutter_args)); #[cfg(not(feature = "flutter"))] - return Some(args); + return Some(CoreMainAction::StartUi(args)); } fn import_config(path: &str) { @@ -873,7 +860,7 @@ fn is_root() -> bool { crate::platform::is_root() } -#[cfg(any(target_os = "linux", target_os = "macos", test))] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool { matches!( args.first().map(String::as_str), @@ -921,6 +908,7 @@ mod tests { values.iter().map(|value| value.to_string()).collect() } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn user_main_ipc_scope_cli_command_matches_management_commands_only() { for command in [ @@ -947,6 +935,22 @@ mod tests { assert!(!is_user_main_ipc_scope_cli_command(&args(&[command]))); } } + + #[cfg(target_os = "macos")] + #[test] + fn macos_manual_update_result_preserves_failure() { + let failure = + finish_macos_update(Err(hbb_common::anyhow::anyhow!("administrator canceled"))) + .expect("failure action"); + + match failure { + CoreMainAction::ExitFailure(err) => { + assert_eq!("administrator canceled", err.to_string()); + } + CoreMainAction::StartUi(_) => panic!("update failure started the UI"), + } + assert!(finish_macos_update(Ok(())).is_none()); + } } /// Check if the executable is a Quick Support version. diff --git a/src/flutter.rs b/src/flutter.rs index 35fa8cc5e..f83f18beb 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -37,6 +37,11 @@ pub(crate) const APP_TYPE_CM: &str = "cm"; #[cfg(any(target_os = "android", target_os = "ios"))] pub(crate) const APP_TYPE_CM: &str = "main"; +#[cfg(target_os = "macos")] +const CORE_MAIN_EXIT_SUCCESS: i32 = 0; +#[cfg(target_os = "macos")] +const CORE_MAIN_EXIT_FAILURE: i32 = 1; + // Do not remove the following constants. // Uncomment them when they are used. // pub(crate) const APP_TYPE_DESKTOP_REMOTE: &str = "remote"; @@ -102,6 +107,8 @@ fn load_plugin_in_app_path(dll_name: &str) -> Result { /// FFI for rustdesk core's main entry. /// Return true if the app should continue running with UI(possibly Flutter), false if the app should exit. +/// On macOS, command-only paths exit here because the Swift caller ignores +/// the return value; update failures must therefore use a non-zero exit status. #[cfg(not(windows))] #[no_mangle] pub extern "C" fn rustdesk_core_main() -> bool { @@ -116,11 +123,18 @@ pub extern "C" fn rustdesk_core_main() -> bool { } } #[cfg(not(any(target_os = "android", target_os = "ios")))] - if crate::core_main::core_main().is_some() { - return true; - } else { - #[cfg(target_os = "macos")] - std::process::exit(0); + match crate::core_main::core_main() { + Some(crate::core_main::CoreMainAction::StartUi(_)) => return true, + Some(crate::core_main::CoreMainAction::ExitFailure(err)) => { + eprintln!("Command failed: {err}"); + log::error!("Command failed: {err}"); + #[cfg(target_os = "macos")] + std::process::exit(CORE_MAIN_EXIT_FAILURE); + } + None => { + #[cfg(target_os = "macos")] + std::process::exit(CORE_MAIN_EXIT_SUCCESS); + } } #[cfg(not(target_os = "macos"))] false @@ -138,8 +152,14 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c unsafe { std::ptr::write(args_len, 0) }; #[cfg(not(any(target_os = "android", target_os = "ios")))] { - if let Some(args) = crate::core_main::core_main() { - return rust_args_to_c_args(args, args_len); + match crate::core_main::core_main() { + Some(crate::core_main::CoreMainAction::StartUi(args)) => { + return rust_args_to_c_args(args, args_len); + } + Some(crate::core_main::CoreMainAction::ExitFailure(err)) => { + log::error!("Command failed: {err}"); + } + None => {} } return std::ptr::null_mut() as _; } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f9cc2d66c..41968c185 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -24,12 +24,11 @@ use base::{ }; use std::{ collections::HashMap, - path::PathBuf, sync::{ atomic::{AtomicI32, Ordering}, Arc, }, - time::{Duration, SystemTime}, + time::SystemTime, }; pub type SessionID = uuid::Uuid; @@ -2727,36 +2726,12 @@ pub fn main_get_common(key: String) -> String { format!("error:{}", e) } } - } else if key.starts_with("download-file-") { - let _version = key.replace("download-file-", ""); - #[cfg(target_os = "windows")] - return match ( - crate::platform::windows::is_msi_installed(), - crate::common::is_custom_client(), - ) { - (Ok(true), false) => match crate::platform::windows::release_arch_suffix() { - Some(arch) => format!("rustdesk-{_version}-{arch}.msi"), - None => "error:unsupported".to_owned(), - }, - (Ok(true), true) | (Ok(false), _) => { - match crate::platform::windows::release_arch_suffix() { - Some(arch) => format!("rustdesk-{_version}-{arch}.exe"), - None => "error:unsupported".to_owned(), - } - } - (Err(e), _) => { - log::error!("Failed to check if is msi: {}", e); - format!("error:update-failed-check-msi-tip") - } - }; - #[cfg(target_os = "macos")] + } else if let Some(release_page_url) = key.strip_prefix("verified-download-url-") { + #[cfg(any(target_os = "windows", target_os = "macos"))] { - return if cfg!(target_arch = "x86_64") { - format!("rustdesk-{_version}-x86_64.dmg") - } else if cfg!(target_arch = "aarch64") { - format!("rustdesk-{_version}-aarch64.dmg") - } else { - "error:unsupported".to_owned() + return match verified_update_artifact_for_release_page_url(release_page_url) { + Ok(artifact) => artifact.url, + Err(e) => format!("error:{}", e), }; } #[cfg(not(any(target_os = "windows", target_os = "macos")))] @@ -2770,9 +2745,35 @@ pub fn main_get_common(key: String) -> String { } pub fn main_get_common_sync(key: String) -> SyncReturn { + // Metadata verification performs blocking network requests. Keep this key on + // the asynchronous FFI path to avoid blocking the calling Flutter isolate. + if key.starts_with("verified-download-url-") { + return SyncReturn("error:unsupported".to_owned()); + } 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(any(target_os = "windows", target_os = "macos"))] +fn verified_update_artifact_for_release_page_url( + release_page_url: &str, +) -> ResultType { + #[cfg(target_os = "windows")] + let update_msi = crate::platform::windows::is_msi_installed()? && !crate::is_custom_client(); + #[cfg(target_os = "macos")] + let update_msi = false; + let query = crate::updater::current_update_artifact_query(update_msi); + crate::updater::verified_update_artifact_for_release_page_url(release_page_url, query) +} + pub fn main_set_common(_key: String, _value: String) { #[cfg(target_os = "windows")] if _key == "install-printer" && crate::platform::is_win_10_or_greater() { @@ -2808,65 +2809,94 @@ pub fn main_set_common(_key: String, _value: String) { } #[cfg(any(target_os = "windows", target_os = "macos"))] { - use crate::updater::get_download_file_from_url; + use crate::updater::get_update_download_file_from_url; if _key == "download-new-version" { 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())]), - } - } else { - HashMap::from([ - ("name", event_key), - ("error", "Invalid download url".to_string()), - ]) - }; + let data = + match crate::updater::verified_update_artifact_for_download_url(&download_url) { + Ok(artifact) => { + if let Some(download_file) = + get_update_download_file_from_url(&artifact.url) + { + let options = crate::hbbs_http::downloader::DownloadOptions { + expected_size: artifact.size, + artifact_sha256: artifact.sha256, + }; + match crate::hbbs_http::downloader::download_file( + artifact.url, + Some(download_file), + options, + ) { + Ok(id) => HashMap::from([("name", event_key), ("id", id)]), + Err(e) => { + HashMap::from([("name", event_key), ("error", e.to_string())]) + } + } + } else { + HashMap::from([ + ("name", event_key), + ("error", "Invalid download url".to_owned()), + ]) + } + } + Err(e) => HashMap::from([ + ("name", event_key), + ("error", format!("Failed to verify update metadata, {}", e)), + ]), + }; let _res = flutter::push_global_event( flutter::APP_TYPE_MAIN, serde_json::ser::to_string(&data).unwrap_or("".to_owned()), ); } else if _key == "update-me" { - if let Some(new_version_file) = get_download_file_from_url(&_value) { - log::debug!( - "New version file is downloaded, update begin, {:?}", - new_version_file.to_str() - ); - 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) { - Ok(_) => { - log::info!("Update process is launched successfully!"); - } - Err(e) => { - log::error!("Failed to update to new version, {}", e); - fs::remove_file(f).ok(); - } - } - } - } - } else if _key == "extract-update-dmg" { - #[cfg(target_os = "macos")] + let artifact = match crate::updater::verified_update_artifact_for_download_url(&_value) { - 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); + Ok(artifact) => artifact, + Err(e) => { + let error = format!("Failed to verify update metadata, {}", e); + log::error!("{}", error); + push_update_me_error(error); + return; + } + }; + let Some(new_version_file) = get_update_download_file_from_url(&artifact.url) else { + let error = "Failed to resolve downloaded update file path".to_owned(); + log::error!("{}", error); + push_update_me_error(error); + return; + }; + let Some(new_version_file_str) = new_version_file.to_str() else { + let error = "Invalid UTF-8 path for downloaded update file".to_owned(); + log::error!("{}", error); + push_update_me_error(error); + return; + }; + log::debug!( + "New version file is downloaded, update begin, {:?}", + new_version_file_str + ); + #[cfg(target_os = "windows")] + let update_res = crate::platform::update_to_verified( + new_version_file_str, + &artifact.sha256, + artifact.size, + ); + #[cfg(target_os = "macos")] + let update_res = crate::platform::macos::update_to_verified_dmg( + new_version_file_str, + &artifact.sha256, + Some(artifact.size), + ); + if update_res.is_err() { + crate::updater::remove_update_file(&new_version_file); + } + match update_res { + Ok(_) => log::info!("Update process is launched successfully!"), + Err(e) => { + let error = format!("Failed to update to new version, {}", e); + log::error!("{}", error); + push_update_me_error(error); } } } @@ -3056,3 +3086,15 @@ pub mod server_side { jboolean::from(crate::server::is_clipboard_service_ok()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sync_common_rejects_verified_download_url() { + let result = main_get_common_sync("verified-download-url-".to_owned()); + + assert_eq!(result.0, "error:unsupported"); + } +} diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index a4fd1fa32..7cb4bcdb6 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -5,14 +5,14 @@ use hbb_common::{ log, tokio::{ self, - fs::File, + fs::{File, OpenOptions}, io::AsyncWriteExt, sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, }, ResultType, }; use serde_derive::Serialize; -use std::{collections::HashMap, path::PathBuf, sync::Mutex, time::Duration}; +use std::{collections::HashMap, path::PathBuf, sync::Mutex}; lazy_static! { static ref DOWNLOADERS: Mutex> = Default::default(); @@ -31,6 +31,7 @@ pub struct DownloadData { #[serde(skip_serializing_if = "Option::is_none")] pub total_size: Option, pub downloaded_size: u64, + pub finished: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -38,181 +39,187 @@ pub struct DownloadData { struct Downloader { data: Vec, path: Option, - // Some file may be empty, so we use Option to indicate if the size is known - total_size: Option, + total_size: u64, + artifact_sha256: String, downloaded_size: u64, error: Option, finished: bool, tx_cancel: UnboundedSender<()>, } +impl Downloader { + fn matches_request(&self, path: &Option, options: &DownloadOptions) -> bool { + self.path.as_ref() == path.as_ref() + && self.total_size == options.expected_size + && self.artifact_sha256 == options.artifact_sha256 + } +} + +pub struct DownloadOptions { + pub expected_size: u64, + pub artifact_sha256: String, +} + +struct DownloadRequest { + id: String, + url: String, + path: Option, + expected_size: u64, +} + // The caller should check if the file is downloaded successfully and remove the job from the map. pub fn download_file( url: String, path: Option, - auto_del_dur: Option, + options: DownloadOptions, ) -> ResultType { let id = url.clone(); - // First pass: if a non-error downloader exists for this URL, reuse it. - // If an errored downloader exists, remove it so this call can retry. - let mut stale_path = None; - { - let mut downloaders = DOWNLOADERS.lock().unwrap(); - if let Some(downloader) = downloaders.get(&id) { - if downloader.error.is_none() { - return Ok(id); - } - stale_path = downloader.path.clone(); - downloaders.remove(&id); - } - } - 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); - } - } - } - - if let Some(path) = path.as_ref() { - if path.exists() { - bail!("File {} already exists", path.display()); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - } let (tx, rx) = unbounded_channel(); - let downloader = Downloader { - data: Vec::new(), - path: path.clone(), - total_size: None, - downloaded_size: 0, - error: None, - tx_cancel: tx, - finished: false, - }; - // Second pass (atomic with insert) to avoid race with another concurrent caller. - let mut stale_path_after_check = None; { let mut downloaders = DOWNLOADERS.lock().unwrap(); if let Some(existing) = downloaders.get(&id) { if existing.error.is_none() { - return Ok(id); - } - stale_path_after_check = existing.path.clone(); - downloaders.remove(&id); - } - downloaders.insert(id.clone(), downloader); - } - 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); + if existing.matches_request(&path, &options) { + return Ok(id); + } + bail!("Existing download job does not match requested artifact"); } } - } - let id2 = id.clone(); - std::thread::spawn( - move || match do_download(&id2, url, path, auto_del_dur, rx) { - Ok(is_all_downloaded) => { - let mut downloaded_size = 0; - let mut total_size = 0; - DOWNLOADERS.lock().unwrap().get_mut(&id2).map(|downloader| { - downloaded_size = downloader.downloaded_size; - total_size = downloader.total_size.unwrap_or(0); - }); - log::info!( - "Download {} end, {}/{}, {:.2} %", - &id2, - downloaded_size, - total_size, - if total_size == 0 { - 0.0 - } else { - downloaded_size as f64 / total_size as f64 * 100.0 + let stale_path = downloaders + .remove(&id) + .and_then(|downloader| downloader.path); + if stale_path.as_ref() != path.as_ref() { + 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 + ); } - ); + } + } + } - let is_canceled = !is_all_downloaded; - if is_canceled { - if let Some(downloader) = DOWNLOADERS.lock().unwrap().remove(&id2) { - if let Some(p) = downloader.path { - if p.exists() { - std::fs::remove_file(p).ok(); - } + if let Some(path) = path.as_ref() { + prepare_download_path(path)?; + } + + downloaders.insert( + id.clone(), + Downloader { + data: Vec::new(), + path: path.clone(), + total_size: options.expected_size, + artifact_sha256: options.artifact_sha256, + downloaded_size: 0, + error: None, + tx_cancel: tx, + finished: false, + }, + ); + } + + let request = DownloadRequest { + id: id.clone(), + url, + path, + expected_size: options.expected_size, + }; + let worker_id = id.clone(); + std::thread::spawn(move || match do_download(request, rx) { + Ok(is_all_downloaded) => { + let mut downloaded_size = 0; + let mut total_size = 0; + DOWNLOADERS + .lock() + .unwrap() + .get_mut(&worker_id) + .map(|downloader| { + downloaded_size = downloader.downloaded_size; + total_size = downloader.total_size; + }); + log::info!( + "Download {} end, {}/{}, {:.2} %", + &worker_id, + downloaded_size, + total_size, + if total_size == 0 { + 0.0 + } else { + downloaded_size as f64 / total_size as f64 * 100.0 + } + ); + + let is_canceled = !is_all_downloaded; + if is_canceled { + if let Some(downloader) = DOWNLOADERS.lock().unwrap().remove(&worker_id) { + if let Some(p) = downloader.path { + if p.exists() { + std::fs::remove_file(p).ok(); } } } } - Err(e) => { - let err = e.to_string(); - log::error!("Download {}, failed: {}", &id2, &err); - DOWNLOADERS.lock().unwrap().get_mut(&id2).map(|downloader| { + } + Err(e) => { + let err = e.to_string(); + log::error!("Download {}, failed: {}", &worker_id, &err); + DOWNLOADERS + .lock() + .unwrap() + .get_mut(&worker_id) + .map(|downloader| { downloader.error = Some(err); }); - } - }, - ); + } + }); Ok(id) } +fn prepare_download_path(path: &PathBuf) -> ResultType<()> { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(()) +} + #[tokio::main(flavor = "current_thread")] async fn do_download( - id: &str, - url: String, - path: Option, - auto_del_dur: Option, + request: DownloadRequest, mut rx_cancel: UnboundedReceiver<()>, ) -> ResultType { - let client = create_http_client_async_with_url_strict(&url).await?; + let client = create_http_client_async_with_url_strict(&request.url).await?; let mut is_all_downloaded = false; - tokio::select! { - _ = rx_cancel.recv() => { - return Ok(is_all_downloaded); - } - head_resp = client.head(&url).send() => { - match head_resp { - Ok(resp) => { - if resp.status().is_success() { - let total_size = resp - .headers() - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|ct_len| ct_len.to_str().ok()) - .and_then(|ct_len| ct_len.parse::().ok()); - let Some(total_size) = total_size else { - bail!("Failed to get content length"); - }; - DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { - downloader.total_size = Some(total_size); - }); - } else { - bail!("Failed to get content length: {}", resp.status()); - } - } - Err(e) => { - return Err(e.into()); - } - } - } - } - + let mut downloaded_size = 0_u64; let mut response; tokio::select! { _ = rx_cancel.recv() => { return Ok(is_all_downloaded); } - resp = client.get(url).send() => { - response = resp?; + resp = client.get(request.url).send() => { + response = resp?.error_for_status()?; } } let mut dest: Option = None; - if let Some(p) = path { - dest = Some(File::create(p).await?); + if let Some(p) = request.path { + dest = Some( + OpenOptions::new() + .write(true) + .create_new(true) + .open(p) + .await?, + ); } loop { @@ -223,28 +230,42 @@ async fn do_download( chunk = response.chunk() => { match chunk { Ok(Some(chunk)) => { + let next_size = downloaded_size + .checked_add(chunk.len() as u64) + .ok_or_else(|| hbb_common::anyhow::anyhow!("Download size overflow"))?; + if next_size > request.expected_size { + bail!( + "Download size exceeds expected size: expected {}, got {}", + request.expected_size, + next_size + ); + } match dest { Some(ref mut f) => { f.write_all(&chunk).await?; f.flush().await?; - DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { - downloader.downloaded_size += chunk.len() as u64; - }); } None => { - DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { - downloader.data.extend_from_slice(&chunk); - downloader.downloaded_size += chunk.len() as u64; - }); + let mut downloaders = DOWNLOADERS.lock().unwrap(); + let Some(downloader) = downloaders.get_mut(&request.id) else { + bail!("Downloader not found"); + }; + downloader.data.extend_from_slice(&chunk); } } + downloaded_size = next_size; + let mut downloaders = DOWNLOADERS.lock().unwrap(); + let Some(downloader) = downloaders.get_mut(&request.id) else { + bail!("Downloader not found"); + }; + downloader.downloaded_size = downloaded_size; } Ok(None) => { is_all_downloaded = true; break; }, Err(e) => { - log::error!("Download {} failed: {}", id, e); + log::error!("Download {} failed: {}", request.id, e); return Err(e.into()); } } @@ -256,17 +277,19 @@ async fn do_download( f.flush().await?; } - if let Some(ref mut downloader) = DOWNLOADERS.lock().unwrap().get_mut(id) { - downloader.finished = true; + if is_all_downloaded && downloaded_size != request.expected_size { + bail!( + "Download size mismatch: expected {}, got {}", + request.expected_size, + downloaded_size + ); } if is_all_downloaded { - let id_del = id.to_string(); - if let Some(dur) = auto_del_dur { - tokio::spawn(async move { - tokio::time::sleep(dur).await; - DOWNLOADERS.lock().unwrap().remove(&id_del); - }); - } + let mut downloaders = DOWNLOADERS.lock().unwrap(); + let Some(downloader) = downloaders.get_mut(&request.id) else { + bail!("Downloader not found"); + }; + downloader.finished = true; } Ok(is_all_downloaded) } @@ -274,21 +297,18 @@ async fn do_download( pub fn get_download_data(id: &str) -> ResultType { let downloaders = DOWNLOADERS.lock().unwrap(); if let Some(downloader) = downloaders.get(id) { - let downloaded_size = downloader.downloaded_size; - let total_size = downloader.total_size.clone(); - let error = downloader.error.clone(); - let data = if total_size.unwrap_or(0) == downloaded_size && downloader.path.is_none() { + let data = if downloader.finished && downloader.path.is_none() { downloader.data.clone() } else { Vec::new() }; - let path = downloader.path.clone(); let download_data = DownloadData { data, - path, - total_size, - downloaded_size, - error, + path: downloader.path.clone(), + total_size: Some(downloader.total_size), + downloaded_size: downloader.downloaded_size, + finished: downloader.finished, + error: downloader.error.clone(), }; Ok(download_data) } else { @@ -298,8 +318,7 @@ pub fn get_download_data(id: &str) -> ResultType { pub fn cancel(id: &str) { if let Some(downloader) = DOWNLOADERS.lock().unwrap().get(id) { - // downloader.is_canceled.store(true, Ordering::SeqCst); - // The receiver may not be able to receive the cancel signal, so we also set the atomic bool to true + // The receiver may not be able to receive the cancel signal, so keep the job until it exits. let _ = downloader.tx_cancel.send(()); } } diff --git a/src/lib.rs b/src/lib.rs index 649f3ef95..69ca7faf3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,10 @@ pub mod ipc; feature = "flutter" )))] pub mod ui; +pub(crate) mod update_metadata; +pub(crate) mod update_hash; +#[cfg(target_os = "macos")] +mod manual_update; mod version; pub use version::*; #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] diff --git a/src/main.rs b/src/main.rs index dbafa3d0c..336d729b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,18 +16,26 @@ fn main() { common::global_clean(); } -#[cfg(not(any( - target_os = "android", - target_os = "ios", - feature = "flutter" -)))] -fn main() { +#[cfg(not(any(target_os = "android", target_os = "ios", feature = "flutter")))] +fn main() -> std::process::ExitCode { #[cfg(all(windows, not(feature = "inline")))] unsafe { winapi::um::shellscalingapi::SetProcessDpiAwareness(2); } - if let Some(args) = crate::core_main::core_main().as_mut() { - ui::start(args); - } + // Preserve command failures, notably verified macOS offline update failures, + // as a non-zero process exit status for CLI callers. + let exit_code = match crate::core_main::core_main() { + Some(crate::core_main::CoreMainAction::StartUi(mut args)) => { + ui::start(&mut args); + std::process::ExitCode::SUCCESS + } + Some(crate::core_main::CoreMainAction::ExitFailure(err)) => { + eprintln!("Command failed: {err}"); + hbb_common::log::error!("Command failed: {err}"); + std::process::ExitCode::FAILURE + } + None => std::process::ExitCode::SUCCESS, + }; common::global_clean(); + exit_code } diff --git a/src/manual_update.rs b/src/manual_update.rs new file mode 100644 index 000000000..d903f5864 --- /dev/null +++ b/src/manual_update.rs @@ -0,0 +1,229 @@ +use hbb_common::{ + anyhow::{anyhow, Context}, + bail, log, ResultType, +}; +use std::{io::Read, path::Path}; + +const UPDATE_SIDECAR_MAX_BYTES: u64 = 1024 * 1024; +const PUBLIC_MACOS_BUNDLE_ID: &str = "com.carriez.rustdesk"; +const MACOS_UPDATE_USAGE: &str = "Usage: --update [update.dmg --metadata rustdesk-update.json --signature rustdesk-update.json.sig]"; +const LEGACY_UPDATE_WARNING: &str = + "legacy offline update: signed metadata not found; release hash was not verified"; + +#[derive(Debug, Eq, PartialEq)] +enum MacosUpdateArguments<'a> { + CurrentApp, + LegacyDmg(&'a str), + VerifiedDmg { + dmg_path: &'a str, + metadata_path: &'a str, + signature_path: &'a str, + }, +} + +fn parse_macos_update_args(args: &[String]) -> ResultType> { + match args { + [command] if command == "--update" => Ok(MacosUpdateArguments::CurrentApp), + [command, dmg_path] if command == "--update" && dmg_path.ends_with(".dmg") => { + Ok(MacosUpdateArguments::LegacyDmg(dmg_path)) + } + [command, dmg_path, metadata_flag, metadata_path, signature_flag, signature_path] + if command == "--update" + && dmg_path.ends_with(".dmg") + && metadata_flag == "--metadata" + && signature_flag == "--signature" => + { + Ok(MacosUpdateArguments::VerifiedDmg { + dmg_path, + metadata_path, + signature_path, + }) + } + _ => bail!(MACOS_UPDATE_USAGE), + } +} + +fn expected_update_package_id( + is_custom_client: bool, + bundle_id: Option, +) -> ResultType { + let bundle_id = bundle_id + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("client bundle identifier is missing"))?; + if !is_custom_client && bundle_id == PUBLIC_MACOS_BUNDLE_ID { + return Ok(crate::update_metadata::PUBLIC_UPDATE_PACKAGE_ID.to_owned()); + } + Ok(bundle_id) +} + +fn installed_update_package_id() -> ResultType { + expected_update_package_id( + crate::common::is_custom_client(), + crate::platform::macos::get_bundle_id(), + ) +} + +fn read_update_sidecar(path: &str) -> ResultType> { + let path_metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect update sidecar {path}"))?; + if !path_metadata.file_type().is_file() { + bail!("update sidecar is not a regular file: {path}"); + } + if path_metadata.len() > UPDATE_SIDECAR_MAX_BYTES { + bail!("update sidecar exceeds maximum size: {path}"); + } + let file = std::fs::File::open(path) + .with_context(|| format!("failed to open update sidecar {path}"))?; + let mut bytes = Vec::new(); + file.take(UPDATE_SIDECAR_MAX_BYTES.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > UPDATE_SIDECAR_MAX_BYTES { + bail!("update sidecar exceeds maximum size: {path}"); + } + Ok(bytes) +} + +fn update_current_app() -> ResultType<()> { + println!("Starting update process..."); + log::info!("Starting update process..."); + crate::platform::update_me()?; + println!( + "{}", + crate::client::translate("Updated successfully!".to_owned()) + ); + log::info!("Updated successfully!"); + Ok(()) +} + +fn update_legacy_dmg(dmg_path: &str) -> ResultType<()> { + eprintln!("Warning: {LEGACY_UPDATE_WARNING}"); + log::warn!("{LEGACY_UPDATE_WARNING}"); + println!("Updating from DMG: {dmg_path}"); + crate::platform::update_from_dmg(dmg_path)?; + println!("Update process from DMG started successfully."); + Ok(()) +} + +fn update_verified_dmg( + dmg_path: &str, + metadata_path: &str, + signature_path: &str, +) -> ResultType<()> { + let file_name = Path::new(dmg_path) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid update DMG file name"))?; + let metadata = read_update_sidecar(metadata_path)?; + let signature = read_update_sidecar(signature_path)?; + let expected_package_id = installed_update_package_id()?; + let artifact = crate::update_metadata::verify_offline_update_metadata( + &metadata, + &signature, + crate::update_metadata::OfflineUpdateMetadataRequirements { + local_file_name: file_name, + expected_package_id: &expected_package_id, + artifact: crate::updater::current_update_artifact_query(false), + }, + )?; + println!("Updating from DMG verified by signed metadata: {dmg_path}"); + crate::platform::macos::update_to_verified_dmg( + dmg_path, + &artifact.sha256, + Some(artifact.size), + )?; + println!("Verified update process started"); + Ok(()) +} + +pub(crate) fn execute_macos_update(args: &[String]) -> ResultType<()> { + match parse_macos_update_args(args)? { + MacosUpdateArguments::CurrentApp => update_current_app(), + MacosUpdateArguments::LegacyDmg(dmg_path) => update_legacy_dmg(dmg_path), + MacosUpdateArguments::VerifiedDmg { + dmg_path, + metadata_path, + signature_path, + } => update_verified_dmg(dmg_path, metadata_path, signature_path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn parses_current_legacy_and_verified_macos_updates() { + assert_eq!( + MacosUpdateArguments::CurrentApp, + parse_macos_update_args(&args(&["--update"])).unwrap() + ); + assert_eq!( + MacosUpdateArguments::LegacyDmg("update.dmg"), + parse_macos_update_args(&args(&["--update", "update.dmg"])).unwrap() + ); + assert_eq!( + MacosUpdateArguments::VerifiedDmg { + dmg_path: "update.dmg", + metadata_path: "rustdesk-update.json", + signature_path: "rustdesk-update.json.sig", + }, + parse_macos_update_args(&args(&[ + "--update", + "update.dmg", + "--metadata", + "rustdesk-update.json", + "--signature", + "rustdesk-update.json.sig", + ])) + .unwrap() + ); + } + + #[test] + fn rejects_partial_reordered_or_extra_macos_update_arguments() { + for invalid in [ + args(&["--update", "update.zip"]), + args(&["--update", "update.dmg", "--metadata", "update.json"]), + args(&[ + "--update", + "update.dmg", + "--signature", + "update.sig", + "--metadata", + "update.json", + ]), + args(&["--update", "update.dmg", "unexpected"]), + ] { + assert!(parse_macos_update_args(&invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn resolves_public_and_custom_update_package_identity() { + assert_eq!( + "rustdesk", + expected_update_package_id(false, Some("com.carriez.rustdesk".to_owned())).unwrap() + ); + assert_eq!( + "com.example.rustdesk-custom", + expected_update_package_id(true, Some("com.example.rustdesk-custom".to_owned())) + .unwrap() + ); + assert_eq!( + "com.example.same-name-custom", + expected_update_package_id(false, Some("com.example.same-name-custom".to_owned())) + .unwrap() + ); + assert_eq!( + "com.carriez.rustdesk", + expected_update_package_id(true, Some("com.carriez.rustdesk".to_owned())).unwrap() + ); + assert!(expected_update_package_id(false, None).is_err()); + assert!(expected_update_package_id(true, None).is_err()); + assert!(expected_update_package_id(true, Some(String::new())).is_err()); + } +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 4386e07ee..334ae40e2 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -2,7 +2,14 @@ // https://github.com/servo/core-foundation-rs // https://github.com/rust-windowing/winit -use super::{CursorData, ResultType}; +mod update_temp; +mod verified_dmg; + +#[cfg(test)] +#[path = "macos/verified_dmg_tests.rs"] +mod verified_dmg_tests; + +use super::{validate_install_app_name, CursorData, ResultType}; use cocoa::{ appkit::{NSApp, NSApplication, NSApplicationActivationPolicy::*}, base::{id, nil, BOOL, NO, YES}, @@ -28,12 +35,17 @@ 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, path::{Path, PathBuf}, process::{Command, Stdio}, sync::Mutex, }; +pub use update_temp::try_remove_temp_update_dir; +use update_temp::{copy_dmg_to_update_temp_file, get_update_temp_dir_string}; +use verified_dmg::{ + attach_dmg, copy_and_verify_dmg_file, extract_dmg_inner, verified_dmg_path, verify_stored_dmg, + VerifiedDmg, +}; // macOS boolean_t is defined as `int` in type BooleanT = hbb_common::libc::c_int; @@ -41,17 +53,42 @@ 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; +const UPDATE_CLEANUP_FAILED_AFTER_COMMIT: &str = "UPDATE_CLEANUP_FAILED_AFTER_COMMIT"; +// `kill -9` may not work without administrator privileges. +// App-only update transaction used when launchd jobs are absent or intentionally stopped. +// Keep its verification, staging, bundle rollback, and cleanup aligned with update.scpt. +const PRIVILEGED_UPDATE_BODY: &str = r#" + on run {app_name, cur_pid, source_path, user_name, restore_owner, expected_sha256, expected_bundle_id} + set app_bundle to "/Applications/" & app_name & ".app" + set app_bundle_q to quoted form of app_bundle + set installed_info_q to quoted form of (app_bundle & "/Contents/Info.plist") + set source_path_q to quoted form of source_path + set user_name_q to quoted form of user_name + set expected_sha256_q to quoted form of expected_sha256 + set expected_bundle_id_q to quoted form of expected_bundle_id -#[inline] -fn get_update_temp_dir() -> PathBuf { - let euid = unsafe { hbb_common::libc::geteuid() }; - Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) -} + set check_source to "if [ -n " & expected_sha256_q & " ]; then test -f " & source_path_q & "; else test -d " & source_path_q & "; fi;" + 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;" + -- Rehash the root-owned copy in a clean environment before staging bytes. + set prepare_verified to "verified_dir=$(/usr/bin/mktemp -d /tmp/.rustdeskupdate-verified.XXXXXX); /bin/chmod 0700 \"$verified_dir\"; verified_app=\"$verified_dir/" & app_name & ".app\"; dmg_attached=0; if [ -n " & expected_sha256_q & " ]; then verified_dmg=\"$verified_dir/update.dmg\"; /bin/cp " & source_path_q & " \"$verified_dmg\"; /usr/sbin/chown root:wheel \"$verified_dmg\"; /bin/chmod 0400 \"$verified_dmg\"; actual_sha256=$(/usr/bin/env -i /usr/bin/shasum -a 256 \"$verified_dmg\"); actual_sha256=${actual_sha256%% *}; if [ \"$actual_sha256\" != " & expected_sha256_q & " ]; then echo 'Update DMG SHA256 mismatch' >&2; exit 1; fi; dmg_mount=\"$verified_dir/mount\"; /bin/mkdir \"$dmg_mount\"; dmg_attached=1; /usr/bin/hdiutil attach -readonly -nobrowse -mountpoint \"$dmg_mount\" \"$verified_dmg\" >/dev/null; /usr/bin/ditto \"$dmg_mount/" & app_name & ".app\" \"$verified_app\"; /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null; dmg_attached=0; /bin/rm -f \"$verified_dmg\"; else /usr/bin/ditto " & source_path_q & " \"$verified_app\"; fi; /usr/sbin/chown -R root:wheel \"$verified_app\"; /bin/chmod -R go-w \"$verified_app\";" + set validate_verified_app to "candidate_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$verified_app/Contents/Info.plist\"); if [ -z " & expected_bundle_id_q & " ] || [ \"$candidate_bundle_id\" != " & expected_bundle_id_q & " ]; then echo 'Update app bundle identifier mismatch' >&2; exit 1; fi; if [ -e " & app_bundle_q & " ]; then installed_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' " & installed_info_q & "); if [ \"$installed_bundle_id\" != " & expected_bundle_id_q & " ]; then echo 'Installed app bundle identifier mismatch' >&2; exit 1; fi; fi;" + 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);" + set move_current_bundle to "if [ -e " & app_bundle_q & " ]; then mv " & app_bundle_q & " \"$old_bundle\"; bundle_backed_up=1; fi;" + set install_staged_bundle to "mv \"$temp_bundle\" " & app_bundle_q & "; bundle_swapped=1;" + set restore_installed_owner to "if [ " & quoted form of restore_owner & " = '1' ]; then chown -R " & user_name_q & ":staff " & app_bundle_q & "; fi;" + set rollback_bundle to "if [ \"${bundle_backed_up:-0}\" -eq 1 ]; then if [ ! -e \"$old_bundle\" ]; then rollback_status=1; elif ! rm -rf " & app_bundle_q & "; then rollback_status=1; elif ! mv \"$old_bundle\" " & app_bundle_q & "; then rollback_status=1; fi; elif [ \"${bundle_swapped:-0}\" -eq 1 ]; then rm -rf " & app_bundle_q & " || rollback_status=1; fi;" + set cleanup_verified to "if [ \"${dmg_attached:-0}\" -eq 1 ]; then /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null 2>&1 || cleanup_status=1; fi; if [ -n \"${temp_bundle:-}\" ]; then rm -rf \"$temp_bundle\" || cleanup_status=1; fi; if [ -n \"${verified_dir:-}\" ]; then rm -rf \"$verified_dir\" || cleanup_status=1; fi;" + set rollback_update to "status=$?; trap - EXIT; set +e; cleanup_status=0; if [ \"${transaction_started:-0}\" -eq 1 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then rollback_status=0;" & rollback_bundle & "if [ \"$rollback_status\" -ne 0 ]; then status=1; fi; fi; if [ \"${rollback_status:-0}\" -eq 0 ]; then " & cleanup_verified & "fi; if [ \"$cleanup_status\" -ne 0 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then status=1; elif [ \"$cleanup_status\" -ne 0 ]; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi; exit \"$status\";" + set commit_update to "transaction_committed=1; if ! rm -rf \"$old_bundle\"; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi;" + set copy_files to prepare_swap_paths & cleanup_swap_paths & stage_bundle & protect_staged_bundle & "transaction_started=1;" & move_current_bundle & install_staged_bundle & restore_installed_owner & commit_update + set sh to "set -e; transaction_started=0; transaction_committed=0; bundle_backed_up=0; bundle_swapped=0; trap " & quoted form of rollback_update & " EXIT;" & check_source & prepare_verified & validate_verified_app & kill_others & copy_files -#[inline] -fn get_update_temp_dir_string() -> String { - get_update_temp_dir().to_string_lossy().into_owned() -} + do shell script sh with prompt app_name & " wants to update itself" with administrator privileges + end run + "#; /// Global mutex to serialize CoreGraphics cursor operations. /// This prevents race conditions between cursor visibility (hide depth tracking) @@ -250,56 +287,79 @@ pub fn is_installed_daemon(prompt: bool) -> bool { false } -fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync: bool) { +enum UpdateSource { + AppDir(String), + VerifiedDmg { + path: String, + expected_sha256: String, + }, +} + +fn log_update_cleanup_warning(output: &std::process::Output) { + if String::from_utf8_lossy(&output.stdout).contains(UPDATE_CLEANUP_FAILED_AFTER_COMMIT) { + log::warn!("Update committed, but temporary update cleanup failed"); + } +} + +impl UpdateSource { + fn into_script_args(self) -> (String, String) { + match self { + Self::AppDir(path) => (path, String::new()), + Self::VerifiedDmg { + path, + expected_sha256, + } => (path, expected_sha256), + } + } +} + +fn update_daemon_agent(agent_plist_file: String, update_source: UpdateSource) -> 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; - }; - let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { - return; - }; - let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { - return; - }; - let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { - return; - }; - - let func = move || { + let current_pid = std::process::id().to_string(); + let (update_source_path, expected_sha256) = update_source.into_script_args(); + let func = move || -> ResultType<()> { let mut binding = std::process::Command::new("osascript"); let cmd = binding .arg("-e") .arg(update_script_body) - .arg(daemon_plist_body) - .arg(agent_plist_body) .arg(&get_active_username()) - .arg(std::process::id().to_string()) - .arg(update_source_dir); - match cmd.status() { + .arg(¤t_pid) + .arg(update_source_path) + .arg(expected_sha256); + 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() + ); } - _ => { + Ok(output) => { + log_update_cleanup_warning(&output); let installed = std::path::Path::new(&agent_plist_file).exists(); log::info!("Agent file {} installed: {}", &agent_plist_file, installed); } } + Ok(()) }; - if sync { - func(); - } else { - std::thread::spawn(func); - } + func() } fn correct_app_name(s: &str) -> String { @@ -882,23 +942,7 @@ 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(); - } -} - 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), - ); - let cmd = std::env::current_exe()?; // RustDesk.app/Contents/MacOS/RustDesk let app_dir = cmd @@ -909,53 +953,78 @@ 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) +} +fn update_me_from_app_dir(app_dir: String) -> ResultType<()> { + update_me_from_source(UpdateSource::AppDir(app_dir)) +} + +fn update_me_from_source(update_source: UpdateSource) -> ResultType<()> { let app_name = crate::get_app_name(); + validate_install_app_name(&app_name)?; + 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), + ); + + // Include launchd jobs in the transaction only while the installed service is enabled. 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, update_source)?; } 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 - - 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 - - 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") + let (update_source_path, expected_sha256) = update_source.into_script_args(); + let expected_bundle_id = get_bundle_id() + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("client bundle identifier is missing"))?; + let output = Command::new("osascript") .arg("-e") - .arg(update_body) + .arg(PRIVILEGED_UPDATE_BODY) .arg(app_name.to_string()) .arg(std::process::id().to_string()) - .arg(app_dir) - .arg(active_user) - .status(); - match status { - Ok(status) if !status.success() => { - log::error!("osascript execution failed with status: {}", status); + .arg(update_source_path) + .arg(get_active_username()) + .arg(if is_installed_daemon { "0" } else { "1" }) + .arg(expected_sha256) + .arg(expected_bundle_id) + .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); } - _ => {} + Ok(output) => log_update_cleanup_warning(&output), } } - std::process::Command::new("open") + match Command::new("open") .arg("-n") .arg(&format!("/Applications/{}.app", app_name)) - .spawn() - .ok(); + .status() + { + Ok(status) if !status.success() => { + log::warn!("Failed to relaunch updated app: {}", status); + } + Err(err) => { + log::warn!("Failed to relaunch updated app: {}", err); + } + _ => {} + } // leave open a little time std::thread::sleep(std::time::Duration::from_millis(300)); Ok(()) @@ -964,16 +1033,38 @@ end run pub fn update_from_dmg(dmg_path: &str) -> ResultType<()> { let update_temp_dir = get_update_temp_dir_string(); println!("Starting update from DMG: {}", dmg_path); - extract_dmg(dmg_path, &update_temp_dir)?; - println!("DMG extracted"); - update_extracted(&update_temp_dir)?; + let update_result = (|| { + let copied_dmg = copy_dmg_to_update_temp_file(dmg_path)?; + let guard = attach_dmg(&copied_dmg.to_string_lossy())?; + update_me_from_app_dir(format!( + "{}/{}.app", + guard.mount_point(), + crate::get_app_name() + )) + })(); + try_remove_temp_update_dir(Some(&update_temp_dir)); + update_result?; println!("Update process started"); Ok(()) } -pub fn update_to(_file: &str) -> ResultType<()> { - let update_temp_dir = get_update_temp_dir_string(); - update_extracted(&update_temp_dir)?; +pub fn update_to_verified_dmg( + file: &str, + expected_sha256: &str, + expected_size: Option, +) -> ResultType<()> { + let verified_dmg = copy_and_verify_dmg_file(file, expected_sha256, expected_size)?; + update_from_verified_dmg(&verified_dmg)?; + // Remove the private DMG copy before this process exits. + drop(verified_dmg); + quit_gui(); + Ok(()) +} + +fn update_from_verified_dmg(verified_dmg: &VerifiedDmg) -> ResultType<()> { + println!("Starting update from verified DMG"); + update_me_from_source(verified_dmg_update_source(verified_dmg)?)?; + println!("Update process started"); Ok(()) } @@ -1021,6 +1112,7 @@ fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultTyp .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".framework")) + && std::fs::symlink_metadata(&child)?.file_type().is_dir() { Some(child.as_path()) } else { @@ -1029,7 +1121,40 @@ fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultTyp validate_update_tree(&child, child_framework_root)?; } } else if !metadata.file_type().is_file() { - bail!("[root-update] unsupported file in update bundle: {}", path.display()); + bail!( + "[root-update] unsupported file in update bundle: {}", + path.display() + ); + } + Ok(()) +} + +fn read_bundle_plist_value(app: &Path, key: &str) -> ResultType { + let info_plist = app.join("Contents/Info.plist"); + let plist_command = format!("Print :{key}"); + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", plist_command.as_str()]) + .arg(info_plist) + .output()?; + if !output.status.success() { + bail!( + "[root-update] failed to read bundle plist value {key}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let value = String::from_utf8(output.stdout) + .map_err(|err| anyhow!("[root-update] bundle plist value {key} is not UTF-8: {err}"))?; + if value.trim().is_empty() { + bail!("[root-update] bundle plist value {key} is empty"); + } + Ok(value.trim().to_owned()) +} + +fn verify_update_app_identity(installed: &Path, candidate: &Path) -> ResultType<()> { + let installed_id = read_bundle_plist_value(installed, "CFBundleIdentifier")?; + let candidate_id = read_bundle_plist_value(candidate, "CFBundleIdentifier")?; + if installed_id != candidate_id { + bail!("[root-update] candidate bundle identifier mismatch"); } Ok(()) } @@ -1038,13 +1163,7 @@ fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultTyp /// Must be called from a process running as root (e.g. the service binary). pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> { let app_name = crate::get_app_name(); - if app_name.is_empty() - || !app_name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - { - bail!("[root-update] unsafe application name"); - } + validate_install_app_name(&app_name)?; let app_bundle = format!("/Applications/{}.app", app_name); let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp") .args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"]) @@ -1073,6 +1192,7 @@ pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> Result let src_app = format!("{}/{}.app", tmp_dir, app_name); log::info!("[root-update] DMG extracted to {}", tmp_dir); validate_update_tree(Path::new(&src_app), None)?; + verify_update_app_identity(Path::new(&app_bundle), Path::new(&src_app))?; // Bind the downloaded asset to the version returned by the update // service before changing plists or executing anything from the staged @@ -1731,127 +1851,23 @@ rm -rf {tmp_dir} Ok(()) } -pub fn extract_update_dmg(file: &str) { - let update_temp_dir = get_update_temp_dir_string(); - let mut evt: HashMap<&str, String> = - HashMap::from([("name", "extract-update-dmg".to_string())]); - match extract_dmg(file, &update_temp_dir) { - Ok(_) => { - log::info!("Extracted dmg file to {}", update_temp_dir); - } - Err(e) => { - evt.insert("err", e.to_string()); - log::error!("Failed to extract dmg file {}: {}", file, e); - } - } - let evt = serde_json::ser::to_string(&evt).unwrap_or("".to_owned()); - #[cfg(feature = "flutter")] - crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, evt); -} - -fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let target_path = Path::new(target_dir); - if target_path.exists() { - std::fs::remove_dir_all(target_path)?; - } - std::fs::create_dir_all(target_path)?; - extract_dmg_inner(dmg_path, target_dir) -} - fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> { let target_path = Path::new(target_dir); if !target_path.exists() { - bail!("[root-update] Temp directory does not exist: {:?}", target_path); + bail!( + "[root-update] Temp directory does not exist: {:?}", + target_path + ); } extract_dmg_inner(dmg_path, target_dir) } -fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let mount_output = Command::new("/usr/bin/mktemp") - .args(["-d", "/tmp/.rustdeskmount-XXXXXX"]) - .output()?; - if !mount_output.status.success() { - bail!("Failed to create a private DMG mount directory"); - } - let mount_point = String::from_utf8(mount_output.stdout) - .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? - .trim() - .to_owned(); - if mount_point.is_empty() { - bail!("Failed to create a private DMG mount directory"); - } - let status = Command::new("/usr/bin/hdiutil") - .args(["attach", "-nobrowse", "-mountpoint"]) - .arg(&mount_point) - .arg(dmg_path) - .status()?; - - if !status.success() { - let _ = std::fs::remove_dir(&mount_point); - bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); - } - - struct DmgGuard(String); - impl Drop for DmgGuard { - fn drop(&mut self) { - let _ = Command::new("/usr/bin/hdiutil") - .args(["detach", self.0.as_str(), "-force"]) - .status(); - let _ = std::fs::remove_dir(&self.0); - } - } - let _guard = DmgGuard(mount_point.clone()); - - let app_name = format!("{}.app", crate::get_app_name()); - let src_path = format!("{}/{}", mount_point, app_name); - let dest_path = format!("{}/{}", target_dir, app_name); - - let copy_status = Command::new("/usr/bin/ditto") - .args(&[&src_path, &dest_path]) - .status()?; - - if !copy_status.success() { - bail!( - "Failed to copy application from {} to {}: {:?}", - src_path, - dest_path, - copy_status - ); - } - - if !Path::new(&dest_path).exists() { - bail!( - "Copy operation failed - destination not found at {}", - dest_path - ); - } - - Ok(()) -} - -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); - } - }; - Ok(()) +fn verified_dmg_update_source(verified_dmg: &VerifiedDmg) -> ResultType { + verify_stored_dmg(verified_dmg)?; + Ok(UpdateSource::VerifiedDmg { + path: verified_dmg_path(verified_dmg)?.to_owned(), + expected_sha256: verified_dmg.expected_sha256.clone(), + }) } pub fn get_double_click_time() -> u32 { @@ -2050,7 +2066,7 @@ impl WakeLock { } } -fn get_bundle_id() -> Option { +pub(crate) fn get_bundle_id() -> Option { unsafe { let bundle: id = msg_send![class!(NSBundle), mainBundle]; if bundle.is_null() { @@ -2073,3 +2089,82 @@ fn get_bundle_id() -> Option { Some(bundle_id_str) } } + +#[cfg(test)] +mod update_tests { + use super::*; + + #[test] + fn update_tree_rejects_framework_root_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-tree-framework-link-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let app_dir = test_dir.join("RustDesk.app"); + let frameworks_dir = app_dir.join("Contents/Frameworks"); + let external_framework = test_dir.join("External.framework"); + std::fs::create_dir_all(&frameworks_dir).unwrap(); + std::fs::create_dir_all(&external_framework).unwrap(); + std::os::unix::fs::symlink( + &external_framework, + frameworks_dir.join("External.framework"), + ) + .unwrap(); + + let result = validate_update_tree(&app_dir, None); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn update_tree_allows_internal_framework_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-tree-internal-link-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let framework_dir = test_dir.join("RustDesk.app/Contents/Frameworks/Test.framework"); + let versions_dir = framework_dir.join("Versions"); + std::fs::create_dir_all(versions_dir.join("A")).unwrap(); + std::os::unix::fs::symlink("A", versions_dir.join("Current")).unwrap(); + + let result = validate_update_tree(&test_dir.join("RustDesk.app"), None); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert!(result.is_ok()); + } + + #[test] + fn update_app_identity_requires_matching_bundles() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-identity-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let installed = test_dir.join("installed.app"); + let candidate = test_dir.join("candidate.app"); + let info = |bundle_id| { + format!("CFBundleIdentifier{bundle_id}") + }; + for app in [&installed, &candidate] { + std::fs::create_dir_all(app.join("Contents")).unwrap(); + } + std::fs::write( + installed.join("Contents/Info.plist"), + info("com.carriez.rustdesk"), + ) + .unwrap(); + + for (bundle_id, accepted) in [("com.carriez.rustdesk", true), ("example.other", false)] { + std::fs::write(candidate.join("Contents/Info.plist"), info(bundle_id)).unwrap(); + assert_eq!( + verify_update_app_identity(&installed, &candidate).is_ok(), + accepted + ); + } + + std::fs::remove_dir_all(test_dir).unwrap(); + } +} diff --git a/src/platform/macos/update_temp.rs b/src/platform/macos/update_temp.rs new file mode 100644 index 000000000..52bd4626b --- /dev/null +++ b/src/platform/macos/update_temp.rs @@ -0,0 +1,248 @@ +use super::ResultType; +use hbb_common::{bail, log}; +use std::{ + io::{self, Write}, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +static UPDATE_TEMP_DIR: OnceLock = OnceLock::new(); +const UPDATE_TEMP_DMG_CREATE_ATTEMPTS: usize = 16; +const STALE_UPDATE_TEMP_DIR_SECS: u64 = 24 * 60 * 60; + +#[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, + std::process::id(), + hbb_common::rand::random::() + )) +} + +fn legacy_update_temp_dir() -> PathBuf { + let euid = unsafe { hbb_common::libc::geteuid() }; + Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) +} + +fn stale_update_temp_dir_prefix() -> String { + let euid = unsafe { hbb_common::libc::geteuid() }; + format!(".rustdeskupdate-{}-", euid) +} + +fn is_stale_update_temp_dir_name(name: &str) -> bool { + let Some(suffix) = name.strip_prefix(&stale_update_temp_dir_prefix()) else { + return false; + }; + let mut parts = suffix.split('-'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(pid), Some(random), None) + if !pid.is_empty() + && !random.is_empty() + && pid.bytes().all(|byte| byte.is_ascii_digit()) + && random.bytes().all(|byte| byte.is_ascii_digit()) + ) +} + +#[inline] +pub(super) fn get_update_temp_dir_string() -> String { + get_update_temp_dir().to_string_lossy().into_owned() +} + +fn ensure_real_update_temp_dir(path: &Path) -> ResultType<()> { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "Update temp path is not a real directory: {}", + path.display() + ); + } + Ok(()) +} + +pub(super) 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)?; + ensure_real_update_temp_dir(&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)?; + ensure_real_update_temp_dir(&dmg_dir)?; + std::fs::set_permissions(&dmg_dir, std::fs::Permissions::from_mode(0o700))?; + + for _ in 0..UPDATE_TEMP_DMG_CREATE_ATTEMPTS { + let file_path = dmg_dir.join(format!( + "{}-{}-{}.dmg", + crate::get_app_name(), + std::process::id(), + hbb_common::rand::random::() + )); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&file_path); + match file { + Ok(file) => return Ok((file, file_path)), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err.into()), + } + } + + bail!("Failed to create update DMG file") +} + +#[inline] +pub fn try_remove_temp_update_dir(dir: Option<&str>) { + if let Some(dir) = dir { + remove_temp_update_dir(Path::new(dir)); + } else { + remove_temp_update_dir(&legacy_update_temp_dir()); + remove_stale_update_temp_dirs(); + } +} + +fn remove_stale_update_temp_dirs() { + use std::os::unix::fs::MetadataExt as _; + + let current_update_temp_dir = get_update_temp_dir(); + let legacy_update_temp_dir = legacy_update_temp_dir(); + let euid = unsafe { hbb_common::libc::geteuid() }; + let Ok(entries) = std::fs::read_dir("/tmp") else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path == current_update_temp_dir || path == legacy_update_temp_dir { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !is_stale_update_temp_dir_name(name) { + continue; + } + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if !metadata.is_dir() || metadata.uid() != euid || !is_old_update_temp_dir(&metadata) { + continue; + } + remove_temp_update_dir(&path); + } +} + +fn is_old_update_temp_dir(metadata: &std::fs::Metadata) -> bool { + let Ok(modified) = metadata.modified() else { + return false; + }; + modified + .elapsed() + .map(|age| age.as_secs() >= STALE_UPDATE_TEMP_DIR_SECS) + .unwrap_or(false) +} + +fn remove_temp_update_dir(path: &Path) { + match std::fs::remove_dir_all(path) { + Ok(()) => {} + Err(err) + if err.kind() == io::ErrorKind::NotFound + || err.raw_os_error() == Some(hbb_common::libc::ENOTDIR) => {} + Err(err) => { + log::warn!( + "Failed to remove update temp dir {}: {}", + path.display(), + err + ); + } + } +} + +pub(super) fn copy_dmg_to_update_temp_file(dmg_path: &str) -> ResultType { + let metadata = std::fs::symlink_metadata(dmg_path)?; + if !metadata.file_type().is_file() { + bail!("Update DMG path is not a regular file: {}", dmg_path); + } + let mut source_file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_NONBLOCK) + .open(dmg_path)?; + if !source_file.metadata()?.is_file() { + bail!("Update DMG path is not a regular file: {}", dmg_path); + } + let (mut dmg_file, file_path) = create_update_temp_dmg_file()?; + io::copy(&mut source_file, &mut dmg_file)?; + dmg_file.flush()?; + Ok(file_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_dir_cleanup_does_not_follow_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-macos-cleanup-symlink-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let target_dir = test_dir.join("target"); + let link_path = test_dir.join("link"); + let target_file = target_dir.join("file"); + std::fs::create_dir_all(&target_dir).unwrap(); + std::fs::write(&target_file, b"target").unwrap(); + std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); + + remove_temp_update_dir(&link_path); + + assert!(std::fs::symlink_metadata(&link_path).is_err()); + assert!(target_file.exists()); + std::fs::remove_dir_all(test_dir).unwrap(); + } + + #[test] + fn update_temp_dir_rejects_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-macos-temp-dir-symlink-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let target_dir = test_dir.join("target"); + let link_path = test_dir.join("link"); + std::fs::create_dir_all(&target_dir).unwrap(); + std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); + + assert!(ensure_real_update_temp_dir(&link_path).is_err()); + assert!(ensure_real_update_temp_dir(&target_dir).is_ok()); + + std::fs::remove_dir_all(test_dir).unwrap(); + } + + #[test] + fn stale_update_temp_dir_name_requires_owned_randomized_dir() { + let euid = unsafe { hbb_common::libc::geteuid() }; + + assert!(is_stale_update_temp_dir_name(&format!( + ".rustdeskupdate-{euid}-123-456" + ))); + for name in [ + format!(".rustdeskupdate-{euid}"), + format!(".rustdeskupdate-{euid}-abc-456"), + format!(".rustdeskupdate-{euid}-123"), + ".rustdeskupdate-999999-123-456".to_owned(), + "rustdeskupdate-123-456".to_owned(), + ] { + assert!(!is_stale_update_temp_dir_name(&name), "{name}"); + } + } +} diff --git a/src/platform/macos/verified_dmg.rs b/src/platform/macos/verified_dmg.rs new file mode 100644 index 000000000..8fbd802d9 --- /dev/null +++ b/src/platform/macos/verified_dmg.rs @@ -0,0 +1,240 @@ +use super::{update_temp::create_update_temp_dmg_file, ResultType}; +use hbb_common::{anyhow::anyhow, bail, log}; +use std::{ + io::{self, Write}, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + process::Command, +}; + +const UPDATE_DMG_MOUNT_TEMPLATE: &str = "/tmp/.rustdeskmount-XXXXXX"; + +#[derive(Debug)] +pub(super) struct VerifiedDmg { + pub(super) file: std::fs::File, + pub(super) path: PathBuf, + pub(super) expected_sha256: String, +} + +impl Drop for VerifiedDmg { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(&self.path) { + if err.kind() != io::ErrorKind::NotFound { + log::warn!( + "Failed to remove verified DMG copy {}: {}", + self.path.display(), + err + ); + } + } + } +} + +// Retain the named copy until the privileged installer has verified its root-owned copy. +pub(super) fn copy_and_verify_dmg_file( + file: &str, + expected_sha256: &str, + expected_size: Option, +) -> ResultType { + let mut source_file = open_dmg_file(file, expected_size)?; + let mut verified_dmg = create_verified_dmg_file(expected_sha256)?; + let copied_size = io::copy(&mut source_file, &mut verified_dmg.file)?; + if expected_size.is_some_and(|size| size != copied_size) { + bail!("DMG size mismatch for {}", file); + } + verified_dmg.file.flush()?; + verify_dmg_contents(&mut verified_dmg.file, expected_sha256, file)?; + Ok(verified_dmg) +} + +fn open_dmg_file(file: &str, expected_size: Option) -> ResultType { + let path_metadata = std::fs::symlink_metadata(file)?; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + bail!("Update DMG path is not a regular file: {}", file); + } + if expected_size.is_some_and(|size| size != path_metadata.len()) { + bail!("DMG size mismatch for {}", file); + } + let dmg_file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_NONBLOCK) + .open(file)?; + let opened_metadata = dmg_file.metadata()?; + if !opened_metadata.is_file() || expected_size.is_some_and(|size| size != opened_metadata.len()) + { + bail!("DMG changed while opening {}", file); + } + Ok(dmg_file) +} + +fn create_verified_dmg_file(expected_sha256: &str) -> ResultType { + let (file, path) = create_update_temp_dmg_file()?; + Ok(VerifiedDmg { + file, + path, + expected_sha256: expected_sha256.trim().to_ascii_lowercase(), + }) +} + +fn verify_dmg_contents( + dmg_file: &mut std::fs::File, + expected_sha256: &str, + file: &str, +) -> ResultType<()> { + use crate::update_hash::{verify_sha256_reader, Sha256VerificationError}; + + match verify_sha256_reader(dmg_file, expected_sha256) { + Ok(()) => Ok(()), + Err(Sha256VerificationError::InvalidExpected) => { + bail!("Expected DMG SHA256 is malformed for {}", file) + } + Err(Sha256VerificationError::Mismatch { .. }) => { + bail!("SHA256 mismatch for {}", file) + } + Err(Sha256VerificationError::Io(err)) => Err(err.into()), + } +} + +pub(super) struct DmgGuard(String); + +impl DmgGuard { + pub(super) fn mount_point(&self) -> &str { + &self.0 + } +} + +impl Drop for DmgGuard { + fn drop(&mut self) { + match Command::new("/usr/bin/hdiutil") + .args(["detach", self.0.as_str(), "-force"]) + .status() + { + Ok(status) if !status.success() => { + log::warn!("Failed to detach DMG mount {}: {}", self.0, status); + } + Err(err) => log::warn!("Failed to detach DMG mount {}: {}", self.0, err), + _ => {} + } + remove_dmg_mount_point(&self.0); + } +} + +pub(super) fn attach_dmg(dmg_path: &str) -> ResultType { + let mount_point = create_dmg_mount_point()?; + // Update images are input only, so never mount them writable. + let output = match Command::new("/usr/bin/hdiutil") + .args([ + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mount_point.as_str(), + dmg_path, + ]) + .output() + { + Ok(output) => output, + Err(err) => { + remove_dmg_mount_point(&mount_point); + return Err(err.into()); + } + }; + if !output.status.success() { + remove_dmg_mount_point(&mount_point); + bail!( + "Failed to attach DMG image at {dmg_path} to {mount_point}: {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(DmgGuard(mount_point)) +} + +pub(super) fn create_dmg_mount_point() -> ResultType { + let output = Command::new("/usr/bin/mktemp") + .args(["-d", UPDATE_DMG_MOUNT_TEMPLATE]) + .output()?; + if !output.status.success() { + bail!( + "Failed to create a private DMG mount directory: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let mount_point = String::from_utf8(output.stdout) + .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? + .trim() + .to_owned(); + if mount_point.is_empty() { + bail!("Failed to create a private DMG mount directory"); + } + Ok(mount_point) +} + +fn remove_dmg_mount_point(mount_point: &str) { + if let Err(err) = std::fs::remove_dir(mount_point) { + if err.kind() != io::ErrorKind::NotFound { + log::warn!( + "Failed to remove DMG mount directory {}: {}", + mount_point, + err + ); + } + } +} + +pub(super) fn verified_dmg_path(verified_dmg: &VerifiedDmg) -> ResultType<&str> { + use std::os::unix::fs::MetadataExt as _; + + let path_metadata = std::fs::symlink_metadata(&verified_dmg.path)?; + let file_metadata = verified_dmg.file.metadata()?; + if !path_metadata.is_file() + || path_metadata.dev() != file_metadata.dev() + || path_metadata.ino() != file_metadata.ino() + { + bail!("Verified DMG path changed: {}", verified_dmg.path.display()); + } + verified_dmg + .path + .to_str() + .ok_or_else(|| anyhow!("Invalid verified DMG path: {}", verified_dmg.path.display())) +} + +pub(super) fn verify_stored_dmg(verified_dmg: &VerifiedDmg) -> ResultType<()> { + let mut file = verified_dmg.file.try_clone()?; + verify_dmg_contents( + &mut file, + &verified_dmg.expected_sha256, + &verified_dmg.path.to_string_lossy(), + ) +} + +pub(super) fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let guard = attach_dmg(dmg_path)?; + extract_attached_dmg(guard, target_dir) +} + +fn extract_attached_dmg(guard: DmgGuard, target_dir: &str) -> ResultType<()> { + let mount_point = guard.mount_point(); + let app_name = format!("{}.app", crate::get_app_name()); + let src_path = format!("{}/{}", mount_point, app_name); + let dest_path = format!("{}/{}", target_dir, app_name); + let copy_status = Command::new("/usr/bin/ditto") + .args(&[&src_path, &dest_path]) + .status()?; + + if !copy_status.success() { + bail!( + "Failed to copy application from {} to {}: {:?}", + src_path, + dest_path, + copy_status + ); + } + if !Path::new(&dest_path).exists() { + bail!( + "Copy operation failed - destination not found at {}", + dest_path + ); + } + Ok(()) +} diff --git a/src/platform/macos/verified_dmg_tests.rs b/src/platform/macos/verified_dmg_tests.rs new file mode 100644 index 000000000..8c9ca0e2e --- /dev/null +++ b/src/platform/macos/verified_dmg_tests.rs @@ -0,0 +1,208 @@ +use super::{ + verified_dmg::{ + copy_and_verify_dmg_file, create_dmg_mount_point, extract_dmg_inner, verified_dmg_path, + verify_stored_dmg, VerifiedDmg, + }, + verified_dmg_update_source, ResultType, +}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +const TEST_DMG_MARKER: &[u8] = b"verified DMG"; + +fn create_test_dmg(test_dir: &Path) -> PathBuf { + let source_dir = test_dir.join("source"); + let app_dir = source_dir + .join(format!("{}.app", crate::get_app_name())) + .join("Contents"); + std::fs::create_dir_all(&app_dir).unwrap(); + std::fs::write(app_dir.join("marker"), TEST_DMG_MARKER).unwrap(); + let dmg_path = test_dir.join("update.dmg"); + let output = Command::new("/usr/bin/hdiutil") + .args(["create", "-quiet", "-format", "UDZO", "-srcfolder"]) + .arg(&source_dir) + .arg(&dmg_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + dmg_path +} + +fn extract_verified_dmg(verified_dmg: &VerifiedDmg, target_dir: &str) -> ResultType<()> { + let target_path = Path::new(target_dir); + if target_path.exists() { + std::fs::remove_dir_all(target_path)?; + } + std::fs::create_dir_all(target_path)?; + verify_stored_dmg(verified_dmg)?; + extract_dmg_inner(verified_dmg_path(verified_dmg)?, target_dir) +} + +#[test] +fn update_dmg_mount_points_are_unique() { + let first = create_dmg_mount_point().unwrap(); + let second = create_dmg_mount_point().unwrap(); + let are_unique = first != second; + for mount_point in [&first, &second] { + if mount_point.starts_with("/tmp/.rustdeskmount-") { + std::fs::remove_dir(mount_point).unwrap(); + } + } + + assert!(are_unique); +} + +#[test] +fn verified_dmg_extracts_from_named_copy() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-extract-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::create_dir_all(&test_dir).unwrap(); + let dmg_path = create_test_dmg(&test_dir); + let mut dmg_file = std::fs::File::open(&dmg_path).unwrap(); + let sha256 = crate::update_hash::sha256_reader_hex(&mut dmg_file).unwrap(); + let size = dmg_file.metadata().unwrap().len(); + let verified_dmg = + copy_and_verify_dmg_file(&dmg_path.to_string_lossy(), &sha256, Some(size)).unwrap(); + let target_dir = test_dir.join("target"); + + let result = extract_verified_dmg(&verified_dmg, &target_dir.to_string_lossy()); + let marker = std::fs::read( + target_dir + .join(format!("{}.app", crate::get_app_name())) + .join("Contents/marker"), + ); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(marker.unwrap(), TEST_DMG_MARKER); +} + +#[test] +fn verified_dmg_rejects_replaced_path() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-replacement-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let dmg_path = create_test_dmg(&test_dir.join("trusted")); + let mut dmg_file = std::fs::File::open(&dmg_path).unwrap(); + let sha256 = crate::update_hash::sha256_reader_hex(&mut dmg_file).unwrap(); + let size = dmg_file.metadata().unwrap().len(); + let verified_dmg = + copy_and_verify_dmg_file(&dmg_path.to_string_lossy(), &sha256, Some(size)).unwrap(); + let replacement_dmg = create_test_dmg(&test_dir.join("replacement")); + std::fs::remove_file(&verified_dmg.path).unwrap(); + std::fs::rename(replacement_dmg, &verified_dmg.path).unwrap(); + + let result = verified_dmg_update_source(&verified_dmg); + drop(verified_dmg); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(result.is_err()); +} + +#[test] +fn verified_dmg_rejects_in_place_mutation() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-mutation-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let dmg_path = create_test_dmg(&test_dir); + let mut dmg_file = std::fs::File::open(&dmg_path).unwrap(); + let sha256 = crate::update_hash::sha256_reader_hex(&mut dmg_file).unwrap(); + let size = dmg_file.metadata().unwrap().len(); + let verified_dmg = + copy_and_verify_dmg_file(&dmg_path.to_string_lossy(), &sha256, Some(size)).unwrap(); + std::fs::write(&verified_dmg.path, b"tampered").unwrap(); + + let result = verified_dmg_update_source(&verified_dmg); + drop(verified_dmg); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(result + .err() + .unwrap() + .to_string() + .contains("SHA256 mismatch")); +} + +#[test] +fn verified_dmg_rejects_sha256_mismatch() { + let file_path = + std::env::temp_dir().join(format!("rustdesk-verified-dmg-test-{}", std::process::id())); + std::fs::write(&file_path, b"rustdesk").unwrap(); + let result = copy_and_verify_dmg_file( + &file_path.to_string_lossy(), + "0000000000000000000000000000000000000000000000000000000000000000", + Some(8), + ); + + std::fs::remove_file(file_path).unwrap(); + assert!(result.is_err()); +} + +#[test] +fn verified_dmg_rejects_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-symlink-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let target_path = test_dir.join("target.dmg"); + let link_path = test_dir.join("update.dmg"); + std::fs::write(&target_path, b"rustdesk").unwrap(); + std::os::unix::fs::symlink(&target_path, &link_path).unwrap(); + let result = copy_and_verify_dmg_file( + &link_path.to_string_lossy(), + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + Some(8), + ); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert!(result + .unwrap_err() + .to_string() + .contains("not a regular file")); +} + +#[test] +fn verified_dmg_handle_survives_path_replacement() { + use std::io::{Read as _, Seek as _}; + + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-handle-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let file_path = test_dir.join("update.dmg"); + let original_path = test_dir.join("original.dmg"); + std::fs::write(&file_path, b"rustdesk").unwrap(); + + let mut verified_dmg = copy_and_verify_dmg_file( + &file_path.to_string_lossy(), + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + Some(8), + ) + .unwrap(); + std::fs::rename(&file_path, &original_path).unwrap(); + std::fs::write(&file_path, b"tampered").unwrap(); + + verified_dmg.file.seek(std::io::SeekFrom::Start(0)).unwrap(); + let mut contents = Vec::new(); + verified_dmg.file.read_to_end(&mut contents).unwrap(); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert_eq!(contents, b"rustdesk"); +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 2e6061664..665c4fc51 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -33,6 +33,33 @@ use hbb_common::sysinfo::System; #[cfg(not(any(target_os = "android", target_os = "ios")))] use hbb_common::{sysinfo::Pid, ResultType}; use std::sync::{Arc, Mutex}; + +// Restrict names because installers interpolate them into privileged scripts and paths. +#[cfg(any(windows, target_os = "macos"))] +fn validate_install_app_name(app_name: &str) -> ResultType<()> { + let mut bytes = app_name.bytes(); + let Some(first) = bytes.next() else { + hbb_common::bail!("Application name must not be empty."); + }; + // Keep leading hyphens valid on Windows for existing custom clients. macOS + // rejects them because pgrep interprets a leading hyphen as an option. + let valid_first = + first.is_ascii_alphanumeric() || first == b'_' || (cfg!(windows) && first == b'-'); + if !valid_first + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + let valid_prefix = if cfg!(windows) { + "a letter, number, underscore, or hyphen" + } else { + "a letter, number, or underscore" + }; + hbb_common::bail!( + "Application name must start with {valid_prefix} and contain only letters, numbers, hyphens, and underscores." + ); + } + Ok(()) +} + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] pub const SERVICE_INTERVAL: u64 = 300; @@ -212,6 +239,31 @@ pub fn get_pids_of_process_with_first_arg, S2: AsRef>( #[cfg(test)] mod tests { use super::*; + + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn install_app_names_enforce_ascii_command_safety() { + for app_name in [ + "RustDesk", + "RustDesk-2", + "A", + "A-0", + "1RustDesk", + "_RustDesk", + "Rust_Desk", + ] { + assert!(validate_install_app_name(app_name).is_ok(), "{app_name}"); + } + for app_name in ["", "Rust.Desk", "Rust Desk", "RüstDesk", "RustDesk;touch"] { + assert!(validate_install_app_name(app_name).is_err(), "{app_name}"); + } + + #[cfg(windows)] + assert!(validate_install_app_name("-RustDesk").is_ok()); + #[cfg(target_os = "macos")] + assert!(validate_install_app_name("-RustDesk").is_err()); + } + #[test] fn test_cursor_data() { for _ in 0..30 { diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt index 0484c257a..8c02918f6 100644 --- a/src/platform/privileges_scripts/update.scpt +++ b/src/platform/privileges_scripts/update.scpt @@ -1,26 +1,68 @@ -on run {daemon_file, agent_file, user, cur_pid, source_dir} +on run {user, cur_pid, source_path, expected_sha256} + -- RustDesk identifiers in this script are templates. correct_app_name() + -- rewrites them for the installed client before osascript executes it. set agent_plist to "/Library/LaunchAgents/com.carriez.RustDesk_server.plist" set daemon_plist to "/Library/LaunchDaemons/com.carriez.RustDesk_service.plist" set app_bundle to "/Applications/RustDesk.app" + set installed_info_q to quoted form of (app_bundle & "/Contents/Info.plist") + set service_executable to app_bundle & "/Contents/MacOS/service" + set daemon_label to "com.carriez.RustDesk_service" + set daemon_socket to "/tmp/RustDesk-service/ipc_service" + set readiness_attempts to "30" + set write_plist_attempts to "60" - set check_source to "test -d " & quoted form of source_dir & " || exit 1;" - set resolve_uid to "uid=$(id -u " & quoted form of user & " 2>/dev/null || true);" + set source_path_q to quoted form of source_path + set expected_sha256_q to quoted form of expected_sha256 + set daemon_plist_q to quoted form of daemon_plist + set daemon_target_q to quoted form of ("system/" & daemon_label) + set check_source to "if [ -n " & expected_sha256_q & " ]; then test -f " & source_path_q & "; else test -d " & source_path_q & "; fi;" + -- Rehash the root-owned copy in a clean environment before staging bytes. + set prepare_verified to "verified_dir=$(/usr/bin/mktemp -d /tmp/.rustdeskupdate-verified.XXXXXX); /bin/chmod 0700 \"$verified_dir\"; verified_app=\"$verified_dir/RustDesk.app\"; dmg_attached=0; if [ -n " & expected_sha256_q & " ]; then verified_dmg=\"$verified_dir/update.dmg\"; /bin/cp " & source_path_q & " \"$verified_dmg\"; /usr/sbin/chown root:wheel \"$verified_dmg\"; /bin/chmod 0400 \"$verified_dmg\"; actual_sha256=$(/usr/bin/env -i /usr/bin/shasum -a 256 \"$verified_dmg\"); actual_sha256=${actual_sha256%% *}; if [ \"$actual_sha256\" != " & expected_sha256_q & " ]; then echo 'Update DMG SHA256 mismatch' >&2; exit 1; fi; dmg_mount=\"$verified_dir/mount\"; /bin/mkdir \"$dmg_mount\"; dmg_attached=1; /usr/bin/hdiutil attach -readonly -nobrowse -mountpoint \"$dmg_mount\" \"$verified_dmg\" >/dev/null; /usr/bin/ditto \"$dmg_mount/RustDesk.app\" \"$verified_app\"; /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null; dmg_attached=0; /bin/rm -f \"$verified_dmg\"; else /usr/bin/ditto " & source_path_q & " \"$verified_app\"; fi; /usr/sbin/chown -R root:wheel \"$verified_app\"; /bin/chmod -R go-w \"$verified_app\";" + set validate_verified_app to "installed_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' " & installed_info_q & "); candidate_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$verified_app/Contents/Info.plist\"); if [ -z \"$installed_bundle_id\" ] || [ -z \"$candidate_bundle_id\" ] || [ \"$installed_bundle_id\" != \"$candidate_bundle_id\" ]; then echo 'Update app bundle identifier mismatch' >&2; exit 1; fi;" + set resolve_uid to "uid=$(id -u " & quoted form of user & ");" 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 unload_service to "launchctl unload -w " & daemon_plist_q & " || 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;" + -- Update transaction: + -- source -> root-owned verified_app -> .new -> installed app + -- old installed app -> .old + -- failure before transaction starts: clean temporary files; installed state is unchanged + -- failure after transaction starts but before commit: restore app, plists, service, and agent + -- failure after commit: keep the new app and report cleanup errors only + 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 backup_plists to "daemon_plist_backup=\"$verified_dir/daemon.plist\"; agent_plist_backup=\"$verified_dir/agent.plist\"; daemon_plist_existed=0; agent_plist_existed=0; if [ -e " & daemon_plist_q & " ]; then cp -p " & daemon_plist_q & " \"$daemon_plist_backup\"; daemon_plist_existed=1; fi; if [ -e " & quoted form of agent_plist & " ]; then cp -p " & quoted form of agent_plist & " \"$agent_plist_backup\"; agent_plist_existed=1; fi;" + 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);" + set move_current_bundle to "if [ -e " & quoted form of app_bundle & " ]; then mv " & quoted form of app_bundle & " \"$old_bundle\"; bundle_backed_up=1; fi;" + set install_staged_bundle to "mv \"$temp_bundle\" " & quoted form of app_bundle & "; bundle_swapped=1;" + set rollback_bundle to "if [ \"${bundle_backed_up:-0}\" -eq 1 ]; then if [ ! -e \"$old_bundle\" ]; then rollback_status=1; elif ! rm -rf " & quoted form of app_bundle & "; then rollback_status=1; elif ! mv \"$old_bundle\" " & quoted form of app_bundle & "; then rollback_status=1; fi; elif [ \"${bundle_swapped:-0}\" -eq 1 ]; then rm -rf " & quoted form of app_bundle & " || rollback_status=1; fi;" + set rollback_plists to "if [ \"${daemon_plist_existed:-0}\" -eq 1 ]; then cp -p \"$daemon_plist_backup\" " & daemon_plist_q & " || rollback_status=1; else rm -f " & daemon_plist_q & " || rollback_status=1; fi; if [ \"${agent_plist_existed:-0}\" -eq 1 ]; then cp -p \"$agent_plist_backup\" " & quoted form of agent_plist & " || rollback_status=1; else rm -f " & quoted form of agent_plist & " || rollback_status=1; fi;" + set cleanup_verified to "if [ \"${dmg_attached:-0}\" -eq 1 ]; then /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null 2>&1 || cleanup_status=1; fi; if [ -n \"${temp_bundle:-}\" ]; then rm -rf \"$temp_bundle\" || cleanup_status=1; fi; if [ -n \"${verified_dir:-}\" ]; then rm -rf \"$verified_dir\" || cleanup_status=1; 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 & ";" - set load_service to "launchctl load -w " & daemon_plist & ";" + -- Generate plist definitions from the installed release that will run them. + set write_new_plists to "write_new_plists() { " & quoted form of service_executable & " --write-plists & write_pid=$!; for _ in $(/usr/bin/seq 1 " & write_plist_attempts & "); do if ! kill -0 \"$write_pid\" 2>/dev/null; then wait \"$write_pid\"; return $?; fi; sleep 1; done; kill -TERM \"$write_pid\" 2>/dev/null || true; sleep 1; kill -KILL \"$write_pid\" 2>/dev/null || true; wait \"$write_pid\" 2>/dev/null || true; return 124; }; write_new_plists;" + set load_service to "launchctl load -w " & daemon_plist_q & ";" set agent_label_cmd to "agent_label=$(basename " & quoted form of agent_plist & " .plist);" - set bootstrap_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & " || true; else launchctl load -w " & quoted form of agent_plist & " || true; fi;" - 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;" + -- Agent load failure must trigger rollback; restoration remains best effort. + set bootstrap_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & "; else launchctl load -w " & quoted form of agent_plist & "; fi;" + 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 + -- Registration can succeed before a job is ready; keep rollback until both IPC sockets are live. + -- A stale socket still passes -S; inspect open sockets without triggering IPC authentication. + set check_service to "service_info=$(launchctl print " & daemon_target_q & " 2>/dev/null || true); printf '%s\n' \"$service_info\" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && [ -S " & quoted form of daemon_socket & " ] && /usr/sbin/lsof -a -U -- " & quoted form of daemon_socket & " >/dev/null" + set check_agent to "agent_info=$(launchctl print \"gui/$uid/$agent_label\" 2>/dev/null || launchctl print \"user/$uid/$agent_label\" 2>/dev/null || launchctl print \"system/$agent_label\" 2>/dev/null || true); printf '%s\n' \"$agent_info\" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && [ -S \"/tmp/RustDesk-$uid/ipc\" ] && /usr/sbin/lsof -a -U -- \"/tmp/RustDesk-$uid/ipc\" >/dev/null" + set wait_for_service to "service_ready=0; for _ in $(/usr/bin/seq 1 " & readiness_attempts & "); do if " & check_service & "; then service_ready=1; break; fi; sleep 1; done; [ \"$service_ready\" -eq 1 ];" + set wait_for_agent to "agent_ready=0; for _ in $(/usr/bin/seq 1 " & readiness_attempts & "); do if " & check_agent & "; then agent_ready=1; break; fi; sleep 1; done; [ \"$agent_ready\" -eq 1 ];" + set verify_readiness to check_service & " || exit 1;" & check_agent & " || exit 1;" + set restore_service to "launchctl load -w " & daemon_plist_q & " || rollback_status=1;" + set restore_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & " || rollback_status=1; else launchctl load -w " & quoted form of agent_plist & " || rollback_status=1; fi;" + set rollback_update to "status=$?; trap - EXIT; set +e; cleanup_status=0; if [ \"${transaction_started:-0}\" -eq 1 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then rollback_status=0;" & unload_agent & unload_service & rollback_bundle & rollback_plists & restore_service & restore_agent & "if [ \"$rollback_status\" -ne 0 ]; then status=1; fi; fi; if [ \"${rollback_status:-0}\" -eq 0 ]; then " & cleanup_verified & "fi; if [ \"$cleanup_status\" -ne 0 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then status=1; elif [ \"$cleanup_status\" -ne 0 ]; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi; exit \"$status\";" + set commit_update to "transaction_committed=1; if ! rm -rf \"$old_bundle\"; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi;" - 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; transaction_started=0; transaction_committed=0; bundle_backed_up=0; bundle_swapped=0; trap " & quoted form of rollback_update & " EXIT;" & check_source & prepare_verified & validate_verified_app & resolve_uid & prepare_swap_paths & cleanup_swap_paths & backup_plists & stage_bundle & protect_staged_bundle & "transaction_started=1;" & unload_agent & unload_service & kill_others & move_current_bundle & install_staged_bundle & write_new_plists & load_service & wait_for_service & load_agent & wait_for_agent & verify_readiness & commit_update do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges end run diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 438baf729..6eb21327f 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,4 +1,4 @@ -use super::{CursorData, ResultType}; +use super::{validate_install_app_name, CursorData, ResultType}; use crate::{ common::PORTABLE_APPNAME_RUNTIME_ENV_KEY, custom_server::*, @@ -99,6 +99,7 @@ use winreg::{enums::*, RegKey}; mod acl; mod installer_handoff; mod installer_shell; +mod verified_update; mod msi_registry; pub(crate) use acl::current_process_user_sid_string; pub use acl::{ @@ -111,6 +112,10 @@ use installer_shell::{ embedded_shortcut_commands, embedded_tray_shortcut_commands, escape_nested_cmd_ampersands, shortcut_bytes, validate_install_value, }; +pub use verified_update::{ + copy_and_verify_update_file_sha256, schedule_current_verified_update_file_cleanup, + update_to_verified, VerifiedUpdateFile, +}; pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; @@ -127,17 +132,6 @@ const MSI_EXIT_SUCCESS_REBOOT_INITIATED: u32 = 1641; const MSI_EXIT_SUCCESS_REBOOT_REQUIRED: u32 = 3010; const HKLM_PREFIX: &str = "HKEY_LOCAL_MACHINE\\"; -fn validate_install_app_name(app_name: &str) -> ResultType<()> { - if app_name.is_empty() - || !app_name - .chars() - .all(|character| character.is_ascii_alphanumeric() || character == '-') - { - bail!("Application name must match [a-zA-Z0-9-]+"); - } - Ok(()) -} - pub fn get_focused_display(displays: Vec) -> Option { unsafe { let hwnd = GetForegroundWindow(); @@ -3880,33 +3874,6 @@ 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!() - bail!("Unsupported update file format: {}", file); - } - Ok(()) -} - // 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. @@ -3917,26 +3884,8 @@ pub fn update_to(file: &str) -> ResultType<()> { // 3. Restore the tray app sessions. // `1` and `3` must be done in custom actions. // 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 quiet_args = if quiet { " /qn LAUNCH_TRAY_APP=N" } else { "" }; - let app_name = crate::get_app_name(); - let subkey = format!(".{}", app_name.to_lowercase()); - let printer_installed = get_reg_of_hkcr(&subkey, REG_NAME_INSTALL_PRINTER).as_deref() - != Some("0") - && remote_printer::is_rd_printer_installed(&app_name).unwrap_or_else(|err| { - log::warn!( - "Failed to check printer installation status: {err}; skipping printer installation" - ); - false - }); - let printer_args = if printer_installed { - "" - } else { - " INSTALLPRINTER=0" - }; - let cmds = format!("chcp 65001 && msiexec /i \"{msi}\"{quiet_args}{printer_args} REBOOT=ReallySuppress /norestart"); - run_cmds(cmds, false, "update-msi")?; - Ok(()) +pub fn update_me_msi(msi: &str, expected_sha256: &str, quiet: bool) -> ResultType<()> { + verified_update::install_verified_msi(msi, expected_sha256, quiet) } fn get_import_config(exe: &str) -> String { @@ -4005,9 +3954,10 @@ 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 downloaded installers, verified copies, and abandoned handoff scripts. + if (file_name.starts_with("rustdesk-") + && (file_name.ends_with(".msi") || file_name.ends_with(".exe"))) + || (file_name.starts_with("rustdesk_install_") && file_name.ends_with(".bat")) { // Skip files modified within the last hour to avoid deleting files being downloaded if let Ok(metadata) = std::fs::metadata(&path) { @@ -4202,14 +4152,6 @@ pub fn is_x64() -> bool { unsafe { sys_info.u.s().wProcessorArchitecture == PROCESSOR_ARCHITECTURE_AMD64 } } -pub fn release_arch_suffix() -> Option<&'static str> { - match std::env::consts::ARCH { - "x86_64" => Some("x86_64"), - "aarch64" => Some("aarch64"), - _ => None, - } -} - pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { // Kill rustdesk.exe without extra arg, should only be called by --server // We can find the exact process which occupies the ipc, see more from https://github.com/winsiderss/systeminformer diff --git a/src/platform/windows/verified_update.rs b/src/platform/windows/verified_update.rs new file mode 100644 index 000000000..1cfe95044 --- /dev/null +++ b/src/platform/windows/verified_update.rs @@ -0,0 +1,261 @@ +mod file; + +use super::{ + get_custom_client_staging_dir, get_reg_of_hkcr, + handle_custom_client_staging_dir_before_update, installer_handoff::run_cmds, + installer_shell::path_for_cmd_environment, remove_custom_client_staging_dir, ResultType, + REG_NAME_INSTALL_PRINTER, +}; +use hbb_common::{allow_err, anyhow::anyhow, bail, log}; +use std::path::Path; + +pub use file::{copy_and_verify_update_file_sha256, VerifiedUpdateFile}; + +const VERIFIED_UPDATE_FILE_PREFIX: &str = "rustdesk-verified-"; +const VERIFIED_EXE_FILENAME: &str = "update.exe"; +const VERIFIED_MSI_FILENAME: &str = "update.msi"; +const VERIFIED_UPDATE_COPY_FAILURE_EXIT_CODE: u32 = 0x5253_0009; +const VERIFIED_UPDATE_HASH_FAILURE_EXIT_CODE: u32 = 0x5253_000A; +const VERIFIED_UPDATE_HASH_MISMATCH_EXIT_CODE: u32 = 0x5253_000B; +const VERIFIED_EXE_LAUNCH_FAILURE_EXIT_CODE: u32 = 0x5253_000C; +const SHA256_HASH_LENGTH: usize = 32; + +pub fn update_to_verified(file: &str, expected_sha256: &str, expected_size: u64) -> ResultType<()> { + let extension = file::update_file_extension(file).unwrap_or_default(); + if extension != "exe" && extension != "msi" { + bail!("Unsupported update file format: {}", file); + } + + let update_file = copy_and_verify_update_file_sha256(file, expected_sha256)?; + if let Err(err) = update_file.verify_size(expected_size) { + update_file.cleanup(); + return Err(err); + } + let update_path = match update_file.path_str() { + Ok(path) => path.to_owned(), + Err(err) => { + update_file.cleanup(); + return Err(err); + } + }; + let custom_client_staging_dir = get_custom_client_staging_dir(); + if crate::is_custom_client() { + if let Err(err) = handle_custom_client_staging_dir_before_update(&custom_client_staging_dir) + { + update_file.cleanup(); + return Err(err); + } + } else { + // Clean up any residual staging directory from previous custom client. + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + } + + let result = launch_verified_update(&extension, &update_path, expected_sha256); + clear_custom_client_staging_after_launch_failure(&custom_client_staging_dir, &result); + update_file.cleanup(); + result +} + +fn launch_verified_update( + extension: &str, + update_path: &str, + expected_sha256: &str, +) -> ResultType<()> { + match extension { + "exe" => { + if let Err(err) = launch_verified_exe(update_path, expected_sha256) { + bail!("Failed to run the update exe: {}", err); + } + } + "msi" => { + if let Err(err) = install_verified_msi(update_path, expected_sha256, false) { + bail!("Failed to run the update msi: {}", err); + } + } + _ => bail!("Unsupported update file format: {}", update_path), + } + Ok(()) +} + +fn verified_update_hash_pattern(expected_sha256: &str) -> ResultType { + let hash = hex::decode(expected_sha256.trim()) + .map_err(|err| anyhow!("Invalid expected update SHA-256: {err}"))?; + if hash.len() != SHA256_HASH_LENGTH { + bail!("Invalid expected update SHA-256 length"); + } + // FINDSTR /R /I /X /C matches the complete certutil hash line; ` *` allows + // optional spaces between byte pairs. + Ok(hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" *")) +} + +fn launch_verified_exe(exe: &str, expected_sha256: &str) -> ResultType<()> { + let source = path_for_cmd_environment(Path::new(exe))?; + let hash_pattern = verified_update_hash_pattern(expected_sha256)?; + // Keep the hash outside the EXE directory because update_me() copies that + // entire directory into the installation. + let commands = format!( + "set \"RUSTDESK_VERIFIED_EXE_SOURCE={source}\"\r\n\ + set \"RUSTDESK_VERIFIED_EXE=%RUSTDESK_OUTPUT_DIR%\\{VERIFIED_EXE_FILENAME}\"\r\n\ + copy /B /Y \"%RUSTDESK_VERIFIED_EXE_SOURCE%\" \"%RUSTDESK_VERIFIED_EXE%\" > nul || exit /b {VERIFIED_UPDATE_COPY_FAILURE_EXIT_CODE}\r\n\ + certutil.exe -hashfile \"%RUSTDESK_VERIFIED_EXE%\" SHA256 > \"%~f0.exe.sha256\" || exit /b {VERIFIED_UPDATE_HASH_FAILURE_EXIT_CODE}\r\n\ + findstr.exe /R /I /X /C:\"{hash_pattern}\" \"%~f0.exe.sha256\" > nul || exit /b {VERIFIED_UPDATE_HASH_MISMATCH_EXIT_CODE}\r\n\ + start \"\" /wait \"%RUSTDESK_VERIFIED_EXE%\" --update || exit /b {VERIFIED_EXE_LAUNCH_FAILURE_EXIT_CODE}" + ); + run_cmds(commands, false, "update-exe") +} + +fn verified_msi_install_commands( + msi: &Path, + expected_sha256: &str, + quiet: bool, +) -> ResultType { + use windows::Win32::Foundation::{ + ERROR_SUCCESS, ERROR_SUCCESS_REBOOT_INITIATED, ERROR_SUCCESS_REBOOT_REQUIRED, + }; + + // The open source handle prevents replacing that file, but not retargeting an + // ancestor junction. Copy and reverify it in the protected runner directory. + let source = path_for_cmd_environment(msi)?; + let hash_pattern = verified_update_hash_pattern(expected_sha256)?; + let quiet_args = if quiet { " /qn LAUNCH_TRAY_APP=N" } else { "" }; + let app_name = crate::get_app_name(); + let subkey = format!(".{}", app_name.to_lowercase()); + let printer_installed = get_reg_of_hkcr(&subkey, REG_NAME_INSTALL_PRINTER).as_deref() + != Some("0") + && remote_printer::is_rd_printer_installed(&app_name).unwrap_or_else(|err| { + log::warn!( + "Failed to check printer installation status: {err}; skipping printer installation" + ); + false + }); + let printer_args = if printer_installed { + "" + } else { + " INSTALLPRINTER=0" + }; + Ok(format!( + "set \"RUSTDESK_VERIFIED_MSI_SOURCE={source}\"\r\n\ + set \"RUSTDESK_VERIFIED_MSI=%RUSTDESK_OUTPUT_DIR%\\{VERIFIED_MSI_FILENAME}\"\r\n\ + copy /B /Y \"%RUSTDESK_VERIFIED_MSI_SOURCE%\" \"%RUSTDESK_VERIFIED_MSI%\" > nul || exit /b {VERIFIED_UPDATE_COPY_FAILURE_EXIT_CODE}\r\n\ + certutil.exe -hashfile \"%RUSTDESK_VERIFIED_MSI%\" SHA256 > \"%RUSTDESK_VERIFIED_MSI%.sha256\" || exit /b {VERIFIED_UPDATE_HASH_FAILURE_EXIT_CODE}\r\n\ + findstr.exe /R /I /X /C:\"{hash_pattern}\" \"%RUSTDESK_VERIFIED_MSI%.sha256\" > nul || exit /b {VERIFIED_UPDATE_HASH_MISMATCH_EXIT_CODE}\r\n\ + msiexec.exe /i \"%RUSTDESK_VERIFIED_MSI%\"{quiet_args}{printer_args} REBOOT=ReallySuppress /norestart\r\n\ + set \"RUSTDESK_MSI_EXIT_CODE=%ERRORLEVEL%\"\r\n\ + if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{reboot_initiated}\" exit /b {success}\r\n\ + if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{reboot_required}\" exit /b {success}\r\n\ + exit /b %RUSTDESK_MSI_EXIT_CODE%", + success = ERROR_SUCCESS.0, + reboot_initiated = ERROR_SUCCESS_REBOOT_INITIATED.0, + reboot_required = ERROR_SUCCESS_REBOOT_REQUIRED.0, + )) +} + +pub(super) fn install_verified_msi( + msi: &str, + expected_sha256: &str, + quiet: bool, +) -> ResultType<()> { + run_cmds( + verified_msi_install_commands(Path::new(msi), expected_sha256, quiet)?, + false, + "update-msi", + ) +} + +fn clear_custom_client_staging_after_launch_failure( + staging_dir: &Path, + launch_result: &ResultType<()>, +) { + if launch_result.is_ok() { + return; + } + if let Err(err) = remove_custom_client_staging_dir(staging_dir) { + log::warn!( + "Failed to clear custom client staging directory {:?}: {}", + staging_dir, + err + ); + } +} + +pub fn schedule_current_verified_update_file_cleanup() -> ResultType<()> { + let current_exe = std::env::current_exe()?; + if should_schedule_verified_update_cleanup(¤t_exe) { + schedule_file_removal_on_reboot(¤t_exe)?; + } + Ok(()) +} + +fn should_schedule_verified_update_cleanup(path: &Path) -> bool { + // UAC may run under another account whose TEMP directory differs from the caller's. + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(VERIFIED_UPDATE_FILE_PREFIX) && name.ends_with(".exe")) +} + +fn schedule_file_removal_on_reboot(path: &Path) -> ResultType<()> { + use windows::{ + core::{HSTRING, PCWSTR}, + Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT}, + }; + + unsafe { + MoveFileExW( + &HSTRING::from(path), + PCWSTR::null(), + MOVEFILE_DELAY_UNTIL_REBOOT, + ) + } + .map_err(|err| { + anyhow!( + "Failed to schedule verified update file cleanup for {}: {}", + path.display(), + err + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schedules_cleanup_for_verified_exe_only() { + assert!(should_schedule_verified_update_cleanup(Path::new( + r"C:\Temp\rustdesk-verified-123-456.exe" + ))); + for path in [ + r"C:\Temp\rustdesk-verified-123-456.msi", + r"C:\Temp\rustdesk.exe", + r"C:\Temp\rustdesk-verified-123-456.exe.bak", + ] { + assert!(!should_schedule_verified_update_cleanup(Path::new(path))); + } + } + + #[test] + fn failed_verified_update_launch_clears_staged_custom_client_file() { + let staging_dir = std::env::temp_dir().join(format!( + "rustdesk-update-staging-cleanup-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::create_dir_all(&staging_dir).unwrap(); + let staged_file = staging_dir.join("custom.txt"); + std::fs::write(&staged_file, b"custom client").unwrap(); + let successful_launch: ResultType<()> = Ok(()); + clear_custom_client_staging_after_launch_failure(&staging_dir, &successful_launch); + assert!(staged_file.exists()); + let launch_result: ResultType<()> = Err(hbb_common::anyhow::anyhow!("launch failed")); + + clear_custom_client_staging_after_launch_failure(&staging_dir, &launch_result); + + assert!(!staged_file.exists()); + std::fs::remove_dir_all(staging_dir).unwrap(); + } +} diff --git a/src/platform/windows/verified_update/file.rs b/src/platform/windows/verified_update/file.rs new file mode 100644 index 000000000..527afb263 --- /dev/null +++ b/src/platform/windows/verified_update/file.rs @@ -0,0 +1,314 @@ +use super::{ResultType, VERIFIED_UPDATE_FILE_PREFIX}; +use hbb_common::{anyhow::anyhow, bail, log}; +use std::{ + io::Write, + os::windows::fs::{MetadataExt, OpenOptionsExt}, + path::{Path, PathBuf}, +}; +use winapi::um::{ + winbase::FILE_FLAG_OPEN_REPARSE_POINT, + winnt::{FILE_ATTRIBUTE_REPARSE_POINT, FILE_SHARE_READ}, +}; + +const UPDATE_FILE_COPY_ATTEMPTS: usize = 16; +const UNSAFE_VERIFIED_MSI_PATH_CHARS: &[char] = &['"', '|', '<', '>', '%']; + +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) + } + + pub fn cleanup(self) { + let Self { _file, path } = self; + drop(_file); + remove_verified_update_file(&path); + } + + pub(super) fn verify_size(&self, expected_size: u64) -> ResultType<()> { + let actual_size = self._file.metadata()?.len(); + if actual_size != expected_size { + bail!( + "Update artifact size mismatch for {}: expected {}, got {}", + self.path.display(), + expected_size, + actual_size + ); + } + Ok(()) + } +} + +pub(super) fn update_file_extension(file: &str) -> Option { + Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) +} + +fn verified_update_file_path(file: &str) -> ResultType { + let extension = update_file_extension(file).unwrap_or_default(); + if extension != "exe" && extension != "msi" { + bail!("Unsupported update file format: {}", file); + } + let path = std::env::temp_dir().join(format!( + "{}{}-{}.{}", + VERIFIED_UPDATE_FILE_PREFIX, + std::process::id(), + hbb_common::rand::random::(), + extension + )); + if extension == "msi" { + validate_verified_msi_path(&path)?; + } + Ok(path) +} + +fn validate_verified_msi_path(path: &Path) -> ResultType<()> { + let Some(path_str) = path.to_str() else { + bail!("Invalid verified update file path: {}", path.display()); + }; + if path_str.contains(UNSAFE_VERIFIED_MSI_PATH_CHARS) { + bail!( + "Refusing to use unsafe verified update file path: {}", + path_str + ); + } + Ok(()) +} + +// Reject symlinks, junctions, and other reparse points so verification and +// execution operate on a regular installer file, not a redirected target. +fn is_update_file_attributes_trusted(attributes: u32) -> bool { + attributes & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +// Open reparse points themselves instead of following them, so the +// attribute check can reject redirected update paths. +fn update_file_open_flags() -> u32 { + FILE_FLAG_OPEN_REPARSE_POINT +} + +fn open_update_file_for_verification(file: &str) -> ResultType { + let update_file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(update_file_open_flags()) + .open(file) + .map_err(|err| anyhow!("Failed to lock update file {}: {}", file, err))?; + let metadata = update_file + .metadata() + .map_err(|err| anyhow!("Failed to read update file metadata {}: {}", file, err))?; + if !metadata.is_file() || !is_update_file_attributes_trusted(metadata.file_attributes()) { + bail!("Refusing to verify untrusted update file: {}", file); + } + Ok(update_file) +} + +fn copy_update_file_for_verification(file: &str) -> ResultType { + let mut source_file = open_update_file_for_verification(file)?; + for _ in 0..UPDATE_FILE_COPY_ATTEMPTS { + let path = verified_update_file_path(file)?; + let mut copy_file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(update_file_open_flags()) + .open(&path) + { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(anyhow!( + "Failed to create verified update file {}: {}", + path.display(), + err + ) + .into()) + } + }; + if let Err(err) = std::io::copy(&mut source_file, &mut copy_file) { + drop(copy_file); + remove_verified_update_file(&path); + return Err(err.into()); + } + if let Err(err) = copy_file.flush() { + drop(copy_file); + remove_verified_update_file(&path); + return Err(err.into()); + } + drop(copy_file); + return Ok(path); + } + bail!("Failed to create verified update file for {}", file); +} + +// Keep the verified copy open without write/delete sharing until the installer starts. +pub fn copy_and_verify_update_file_sha256( + file: &str, + expected_sha256: &str, +) -> ResultType { + let path = copy_update_file_for_verification(file)?; + let Some(update_path) = path.to_str().map(str::to_owned) else { + remove_verified_update_file(&path); + bail!("Invalid update file path: {}", path.display()); + }; + + let mut read_file = match open_update_file_for_verification(&update_path) { + Ok(file) => file, + Err(err) => { + remove_verified_update_file(&path); + return Err(err); + } + }; + if let Err(err) = verify_update_file_sha256(&mut read_file, expected_sha256, &update_path) { + drop(read_file); + remove_verified_update_file(&path); + return Err(err); + } + Ok(VerifiedUpdateFile { + _file: read_file, + path, + }) +} + +fn verify_update_file_sha256( + update_file: &mut std::fs::File, + expected_sha256: &str, + file: &str, +) -> ResultType<()> { + use crate::update_hash::{verify_sha256_reader, Sha256VerificationError}; + + match verify_sha256_reader(update_file, expected_sha256) { + Ok(()) => Ok(()), + Err(Sha256VerificationError::InvalidExpected) => { + bail!("Expected update file SHA256 is malformed for {}", file) + } + Err(Sha256VerificationError::Mismatch { + expected_sha256, + actual_sha256, + }) => bail!( + "SHA256 mismatch for {}: expected {}, got {}", + file, + expected_sha256, + actual_sha256 + ), + Err(Sha256VerificationError::Io(err)) => Err(err.into()), + } +} + +fn remove_verified_update_file(path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => log::warn!( + "Failed to remove verified update file {}: {}", + path.display(), + err + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mismatched_sha256_removes_verified_copy() { + let source_path = std::env::temp_dir().join(format!( + "rustdesk-update-source-{}-{}.exe", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::write(&source_path, b"update").unwrap(); + let unrelated_path = std::env::temp_dir().join(format!( + "{}{}-{}.exe", + VERIFIED_UPDATE_FILE_PREFIX, + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::write(&unrelated_path, b"unrelated update").unwrap(); + + let result = + copy_and_verify_update_file_sha256(source_path.to_str().unwrap(), &"0".repeat(64)); + let error = match result { + Ok(update_file) => { + update_file.cleanup(); + panic!("mismatched SHA256 should fail"); + } + Err(error) => error.to_string(), + }; + let verified_path = error + .strip_prefix("SHA256 mismatch for ") + .and_then(|message| message.split_once(": expected ")) + .map(|(path, _)| PathBuf::from(path)) + .expect("mismatch error did not contain verified path"); + + assert!(!verified_path.exists()); + assert!(unrelated_path.exists()); + std::fs::remove_file(source_path).unwrap(); + std::fs::remove_file(unrelated_path).unwrap(); + } + + #[test] + fn verified_update_file_size_must_match_expected_size() { + let source_path = std::env::temp_dir().join(format!( + "rustdesk-update-size-test-{}-{}.exe", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::write(&source_path, b"rustdesk").unwrap(); + let verified_file = copy_and_verify_update_file_sha256( + source_path.to_str().unwrap(), + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + ) + .unwrap(); + + assert!(verified_file.verify_size(8).is_ok()); + assert!(verified_file.verify_size(7).is_err()); + + verified_file.cleanup(); + std::fs::remove_file(source_path).unwrap(); + } + + #[test] + fn rejects_reparse_point_update_file_attributes() { + use winapi::um::winnt::FILE_ATTRIBUTE_NORMAL; + + assert!(is_update_file_attributes_trusted(FILE_ATTRIBUTE_NORMAL)); + assert!(!is_update_file_attributes_trusted( + FILE_ATTRIBUTE_REPARSE_POINT + )); + assert!(!is_update_file_attributes_trusted( + FILE_ATTRIBUTE_NORMAL | FILE_ATTRIBUTE_REPARSE_POINT + )); + } + + #[test] + fn verified_msi_path_rejects_unsafe_cmd_characters() { + for character in ['"', '|', '<', '>', '%'] { + let path = PathBuf::from(format!(r"C:\Temp\unsafe{character}\update.msi")); + assert!( + validate_verified_msi_path(&path).is_err(), + "accepted unsafe character {character:?}" + ); + } + + for character in ['&', '^'] { + let path = PathBuf::from(format!(r"C:\Temp\safe{character}path\update.msi")); + assert!( + validate_verified_msi_path(&path).is_ok(), + "rejected quoted-safe character {character:?}" + ); + } + + assert!(validate_verified_msi_path(Path::new(r"C:\Temp\safe path\update.msi")).is_ok()); + } +} diff --git a/src/service.rs b/src/service.rs index 65be58302..fc22ab772 100644 --- a/src/service.rs +++ b/src/service.rs @@ -6,6 +6,7 @@ fn main() {} #[cfg(target_os = "macos")] fn main() { let args: Vec = std::env::args().collect(); + crate::common::load_custom_client(); if args.len() > 1 && args[1] == "--write-plists" { if let Err(e) = librustdesk::platform::write_plists() { eprintln!("Failed to write plists: {}", e); @@ -13,7 +14,6 @@ fn main() { } std::process::exit(0); } - crate::common::load_custom_client(); hbb_common::init_log(false, "service"); crate::start_os_service(); } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index b1eeefde2..734cb5d79 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -748,13 +748,14 @@ pub fn current_is_wayland() -> bool { #[inline] pub fn get_new_version() -> String { - (*SOFTWARE_UPDATE_URL + SOFTWARE_UPDATE_URL .lock() .unwrap() + .trim_end_matches('/') .rsplit('/') .next() - .unwrap_or("")) - .to_string() + .unwrap_or("") + .to_owned() } #[inline] diff --git a/src/update_hash.rs b/src/update_hash.rs new file mode 100644 index 000000000..5a672e6c8 --- /dev/null +++ b/src/update_hash.rs @@ -0,0 +1,98 @@ +use sha2::{Digest, Sha256}; +use std::io::{self, Read, Seek, SeekFrom}; + +const SHA256_HEX_LENGTH: usize = 64; +const UPDATE_HASH_BUFFER_SIZE: usize = 8192; + +#[derive(Debug)] +pub(crate) enum Sha256VerificationError { + InvalidExpected, + Mismatch { + expected_sha256: String, + actual_sha256: String, + }, + Io(io::Error), +} + +impl From for Sha256VerificationError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +pub(crate) fn sha256_reader_hex(reader: &mut R) -> io::Result { + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; UPDATE_HASH_BUFFER_SIZE]; + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +pub(crate) fn verify_sha256_reader( + reader: &mut R, + expected_sha256: &str, +) -> Result<(), Sha256VerificationError> { + let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); + if expected_sha256.len() != SHA256_HEX_LENGTH + || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(Sha256VerificationError::InvalidExpected); + } + reader.seek(SeekFrom::Start(0))?; + let actual_sha256 = match sha256_reader_hex(reader) { + Ok(actual_sha256) => actual_sha256, + Err(error) => { + reader.seek(SeekFrom::Start(0))?; + return Err(error.into()); + } + }; + reader.seek(SeekFrom::Start(0))?; + if actual_sha256 != expected_sha256 { + return Err(Sha256VerificationError::Mismatch { + expected_sha256, + actual_sha256, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + const RUSTDESK_SHA256: &str = + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed"; + + #[test] + fn verifies_and_rejects_sha256() { + let mut reader = Cursor::new(b"rustdesk".to_vec()); + reader.set_position(reader.get_ref().len() as u64); + + verify_sha256_reader( + &mut reader, + &format!(" {} ", RUSTDESK_SHA256.to_uppercase()), + ) + .unwrap(); + + assert_eq!(reader.position(), 0); + assert!(matches!( + verify_sha256_reader(&mut reader, "invalid"), + Err(Sha256VerificationError::InvalidExpected) + )); + assert!(matches!( + verify_sha256_reader(&mut reader, &"0".repeat(64)), + Err(Sha256VerificationError::Mismatch { .. }) + )); + assert_eq!(reader.position(), 0); + } +} diff --git a/src/update_metadata.rs b/src/update_metadata.rs new file mode 100644 index 000000000..9d2c9ad2d --- /dev/null +++ b/src/update_metadata.rs @@ -0,0 +1,271 @@ +use hbb_common::{ + anyhow::{anyhow, Context}, + base64::{engine::general_purpose::STANDARD, Engine as _}, + sodiumoxide::{ + self, + crypto::sign::{verify_detached, PublicKey, Signature}, + }, + ResultType, +}; +use serde_derive::Deserialize; +use std::sync::OnceLock; + +mod artifact; + +#[cfg(target_os = "macos")] +mod offline; + +use artifact::{validate_artifact, validate_artifact_file_name, ArtifactUrlPolicy}; + +#[cfg(target_os = "macos")] +pub(crate) use offline::{verify_offline_update_metadata, OfflineUpdateMetadataRequirements}; + +const UPDATE_METADATA_SIGNATURE_CONTEXT: &[u8] = b"RustDesk update metadata v1\n"; +const UPDATE_APP: &str = "rustdesk"; +pub(crate) const PUBLIC_UPDATE_PACKAGE_ID: &str = "rustdesk"; +const METADATA_SCHEMA_VERSION: u32 = 1; +const SIGNATURE_SCHEMA_VERSION: u32 = 1; +const SIGNATURE_ALGORITHM: &str = "ed25519"; + +const TRUSTED_UPDATE_KEYS: &[TrustedUpdateKey] = &[TrustedUpdateKey { + key_id: "2026-ed25519-main", + algorithm: SIGNATURE_ALGORITHM, + public_key: [ + 118, 252, 116, 215, 49, 217, 220, 109, 145, 119, 210, 101, 225, 198, 63, 2, 41, 127, 197, + 127, 52, 251, 6, 204, 125, 117, 96, 204, 187, 166, 85, 59, + ], +}]; + +#[derive(Deserialize)] +struct UpdateMetadata { + schema_version: u32, + app: String, + package_id: String, + version: String, + release_id: String, + #[serde(rename = "published_at")] + _published_at: String, + signature_key_id: String, + artifacts: Vec, +} + +#[derive(Deserialize)] +struct UpdateArtifact { + platform: String, + arch: String, + format: String, + url: String, + file_name: String, + size: u64, + sha256: String, +} + +#[derive(Deserialize)] +struct UpdateSignature { + schema_version: u32, + algorithm: String, + key_id: String, + signature: String, +} + +struct TrustedUpdateKey { + key_id: &'static str, + algorithm: &'static str, + public_key: [u8; 32], +} + +#[derive(Clone, Copy)] +pub(crate) struct UpdateArtifactQuery<'a> { + pub platform: &'a str, + pub arch: &'a str, + pub format: &'a str, +} + +#[derive(Clone, Copy)] +pub(crate) struct UpdateMetadataRequirements<'a> { + pub expected_version: &'a str, + pub expected_release_id: &'a str, + pub expected_artifact_url_prefix: &'a str, + pub artifact: UpdateArtifactQuery<'a>, +} + +#[derive(Clone)] +pub(crate) struct VerifiedUpdateArtifact { + pub version: String, + pub url: String, + #[cfg(target_os = "macos")] + pub file_name: String, + pub size: u64, + pub sha256: String, +} + +struct VerificationOptions<'a> { + requirements: MetadataRequirements<'a>, + expected_package_id: &'a str, + artifact_url_policy: ArtifactUrlPolicy<'a>, + trusted_keys: &'a [TrustedUpdateKey], +} + +#[derive(Clone, Copy)] +struct MetadataRequirements<'a> { + expected_version: &'a str, + expected_release_id: &'a str, + artifact: UpdateArtifactQuery<'a>, +} + +fn verify_update_metadata_with_options( + metadata_bytes: &[u8], + signature_bytes: &[u8], + options: VerificationOptions<'_>, +) -> ResultType { + let update_signature: UpdateSignature = + serde_json::from_slice(signature_bytes).context("invalid update signature JSON")?; + validate_signature_schema(&update_signature)?; + let signature = decode_signature(&update_signature.signature)?; + let trusted_key = options + .trusted_keys + .iter() + .find(|key| { + key.key_id == update_signature.key_id && key.algorithm == update_signature.algorithm + }) + .ok_or_else(|| anyhow!("unsupported update signature key"))?; + verify_metadata_signature(metadata_bytes, &signature, trusted_key)?; + let metadata: UpdateMetadata = + serde_json::from_slice(metadata_bytes).context("invalid update metadata JSON")?; + validate_metadata(&metadata, &update_signature, &options)?; + let artifact = select_artifact(&metadata, &options.requirements.artifact)?; + validate_artifact(artifact, options.artifact_url_policy)?; + Ok(VerifiedUpdateArtifact { + version: metadata.version.clone(), + url: artifact.url.clone(), + #[cfg(target_os = "macos")] + file_name: artifact.file_name.clone(), + size: artifact.size, + sha256: artifact.sha256.clone(), + }) +} + +pub(crate) fn verify_update_metadata( + metadata_bytes: &[u8], + signature_bytes: &[u8], + requirements: UpdateMetadataRequirements<'_>, +) -> ResultType { + let artifact_url_policy = + ArtifactUrlPolicy::ExactPrefix(requirements.expected_artifact_url_prefix); + let metadata_requirements = MetadataRequirements { + expected_version: requirements.expected_version, + expected_release_id: requirements.expected_release_id, + artifact: requirements.artifact, + }; + verify_update_metadata_with_options( + metadata_bytes, + signature_bytes, + VerificationOptions { + requirements: metadata_requirements, + expected_package_id: PUBLIC_UPDATE_PACKAGE_ID, + artifact_url_policy, + trusted_keys: TRUSTED_UPDATE_KEYS, + }, + ) +} + +fn validate_signature_schema(signature: &UpdateSignature) -> ResultType<()> { + if signature.schema_version != SIGNATURE_SCHEMA_VERSION { + return Err(anyhow!("unsupported update signature schema version")); + } + if signature.algorithm != SIGNATURE_ALGORITHM { + return Err(anyhow!("unsupported update signature algorithm")); + } + Ok(()) +} + +fn decode_signature(encoded: &str) -> ResultType { + let decoded = STANDARD + .decode(encoded) + .context("invalid update signature base64")?; + if decoded.len() != 64 || STANDARD.encode(&decoded) != encoded { + return Err(anyhow!("invalid update signature length or encoding")); + } + let mut signature = [0u8; 64]; + signature.copy_from_slice(&decoded); + Signature::from_bytes(&signature).map_err(|_| anyhow!("invalid update signature bytes")) +} + +fn verify_metadata_signature( + metadata_bytes: &[u8], + signature: &Signature, + trusted_key: &TrustedUpdateKey, +) -> ResultType<()> { + init_sodiumoxide()?; + let mut signed_bytes = + Vec::with_capacity(UPDATE_METADATA_SIGNATURE_CONTEXT.len() + metadata_bytes.len()); + signed_bytes.extend_from_slice(UPDATE_METADATA_SIGNATURE_CONTEXT); + signed_bytes.extend_from_slice(metadata_bytes); + if !verify_detached(signature, &signed_bytes, &PublicKey(trusted_key.public_key)) { + return Err(anyhow!("invalid update metadata signature")); + } + Ok(()) +} + +fn init_sodiumoxide() -> ResultType<()> { + static INIT: OnceLock> = OnceLock::new(); + let result = INIT.get_or_init(|| { + sodiumoxide::init().map_err(|_| "failed to initialize sodiumoxide".to_string()) + }); + match result { + Ok(()) => Ok(()), + Err(err) => Err(anyhow!(err.clone())), + } +} + +fn validate_metadata( + metadata: &UpdateMetadata, + signature: &UpdateSignature, + options: &VerificationOptions<'_>, +) -> ResultType<()> { + if metadata.schema_version != METADATA_SCHEMA_VERSION { + return Err(anyhow!("unsupported update metadata schema version")); + } + if metadata.signature_key_id != signature.key_id { + return Err(anyhow!("update metadata signature key id mismatch")); + } + if metadata.app != UPDATE_APP { + return Err(anyhow!("update metadata app mismatch")); + } + if metadata.package_id != options.expected_package_id { + return Err(anyhow!("update metadata package id is not allowed")); + } + if options.requirements.expected_version != metadata.version { + return Err(anyhow!("update metadata version mismatch")); + } + if options.requirements.expected_release_id != metadata.release_id { + return Err(anyhow!("update metadata release id mismatch")); + } + for artifact in &metadata.artifacts { + validate_artifact_file_name(&artifact.file_name)?; + } + Ok(()) +} + +fn select_artifact<'a>( + metadata: &'a UpdateMetadata, + query: &UpdateArtifactQuery<'_>, +) -> ResultType<&'a UpdateArtifact> { + let mut matches = metadata.artifacts.iter().filter(|artifact| { + artifact.platform == query.platform + && artifact.arch == query.arch + && artifact.format == query.format + }); + let artifact = matches + .next() + .ok_or_else(|| anyhow!("matching update artifact not found"))?; + if matches.next().is_some() { + return Err(anyhow!("multiple matching update artifacts found")); + } + Ok(artifact) +} + +#[cfg(target_os = "macos")] +#[cfg(test)] +#[path = "update_metadata_tests.rs"] +mod tests; diff --git a/src/update_metadata/artifact.rs b/src/update_metadata/artifact.rs new file mode 100644 index 000000000..a90282078 --- /dev/null +++ b/src/update_metadata/artifact.rs @@ -0,0 +1,84 @@ +use super::UpdateArtifact; +use hbb_common::{ + anyhow::{anyhow, Context}, + ResultType, +}; +use url::Url; + +#[derive(Clone, Copy)] +pub(super) enum ArtifactUrlPolicy<'a> { + ExactPrefix(&'a str), + #[cfg(target_os = "macos")] + SignedHttps, +} + +pub(super) fn validate_artifact( + artifact: &UpdateArtifact, + url_policy: ArtifactUrlPolicy<'_>, +) -> ResultType<()> { + validate_artifact_file_name(&artifact.file_name)?; + if !is_sha256_hex(&artifact.sha256) { + return Err(anyhow!("invalid update artifact sha256")); + } + let parsed_url = Url::parse(&artifact.url).context("invalid update artifact URL")?; + if parsed_url.query().is_some() || parsed_url.fragment().is_some() { + return Err(anyhow!( + "update artifact URL must not contain query or fragment" + )); + } + validate_artifact_url_policy(artifact, &parsed_url, url_policy)?; + let basename = parsed_url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .ok_or_else(|| anyhow!("update artifact URL has no basename"))?; + if basename != artifact.file_name { + return Err(anyhow!("update artifact URL basename mismatch")); + } + Ok(()) +} + +fn validate_artifact_url_policy( + artifact: &UpdateArtifact, + _parsed_url: &Url, + policy: ArtifactUrlPolicy<'_>, +) -> ResultType<()> { + match policy { + ArtifactUrlPolicy::ExactPrefix(prefix) => { + let expected_url = format!("{}{}", prefix, artifact.file_name); + if artifact.url != expected_url { + return Err(anyhow!( + "update artifact URL is outside expected release prefix" + )); + } + } + #[cfg(target_os = "macos")] + ArtifactUrlPolicy::SignedHttps => { + if _parsed_url.scheme() != "https" + || _parsed_url.host_str().is_none() + || !_parsed_url.username().is_empty() + || _parsed_url.password().is_some() + { + return Err(anyhow!( + "signed update artifact URL must be an HTTPS URL without credentials" + )); + } + } + } + Ok(()) +} + +pub(super) fn validate_artifact_file_name(file_name: &str) -> ResultType<()> { + if file_name.is_empty() + || matches!(file_name, "." | "..") + || !file_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(anyhow!("invalid update artifact file name")); + } + Ok(()) +} + +fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.as_bytes().iter().all(u8::is_ascii_hexdigit) +} diff --git a/src/update_metadata/offline.rs b/src/update_metadata/offline.rs new file mode 100644 index 000000000..3177f082a --- /dev/null +++ b/src/update_metadata/offline.rs @@ -0,0 +1,71 @@ +use super::{ + verify_update_metadata_with_options, ArtifactUrlPolicy, MetadataRequirements, TrustedUpdateKey, + UpdateArtifactQuery, VerificationOptions, VerifiedUpdateArtifact, +}; +use hbb_common::{anyhow::Context, bail, ResultType}; +use serde_derive::Deserialize; + +#[derive(Clone, Copy)] +pub(crate) struct OfflineUpdateMetadataRequirements<'a> { + pub local_file_name: &'a str, + pub expected_package_id: &'a str, + pub artifact: UpdateArtifactQuery<'a>, +} + +pub(super) struct OfflineVerificationOptions<'a> { + pub(super) requirements: OfflineUpdateMetadataRequirements<'a>, + pub(super) trusted_keys: &'a [TrustedUpdateKey], +} + +#[derive(Deserialize)] +struct OfflineMetadataHeader { + version: String, + release_id: String, +} + +pub(super) fn verify_offline_update_metadata_with_options( + metadata_bytes: &[u8], + signature_bytes: &[u8], + options: OfflineVerificationOptions<'_>, +) -> ResultType { + let header: OfflineMetadataHeader = + serde_json::from_slice(metadata_bytes).context("invalid update metadata JSON")?; + let expected_version = crate::common::display_version_from_release_id(&header.release_id)?; + if header.version != expected_version { + bail!("offline update metadata version and release id mismatch"); + } + let artifact = verify_update_metadata_with_options( + metadata_bytes, + signature_bytes, + VerificationOptions { + requirements: MetadataRequirements { + expected_version: &expected_version, + expected_release_id: &header.release_id, + artifact: options.requirements.artifact, + }, + expected_package_id: options.requirements.expected_package_id, + artifact_url_policy: ArtifactUrlPolicy::SignedHttps, + trusted_keys: options.trusted_keys, + }, + )?; + if artifact.file_name != options.requirements.local_file_name { + bail!("local update file name does not match signed metadata"); + } + Ok(artifact) +} + +#[cfg(target_os = "macos")] +pub(crate) fn verify_offline_update_metadata( + metadata_bytes: &[u8], + signature_bytes: &[u8], + requirements: OfflineUpdateMetadataRequirements<'_>, +) -> ResultType { + verify_offline_update_metadata_with_options( + metadata_bytes, + signature_bytes, + OfflineVerificationOptions { + requirements, + trusted_keys: super::TRUSTED_UPDATE_KEYS, + }, + ) +} diff --git a/src/update_metadata/offline_tests.rs b/src/update_metadata/offline_tests.rs new file mode 100644 index 000000000..a5b848152 --- /dev/null +++ b/src/update_metadata/offline_tests.rs @@ -0,0 +1,96 @@ +use super::*; +use crate::update_metadata::offline::{ + verify_offline_update_metadata_with_options, OfflineVerificationOptions, +}; + +fn verify_offline(fixture: &Fixture, local_file_name: &str) -> ResultType { + verify_offline_for_package(fixture, local_file_name, PUBLIC_UPDATE_PACKAGE_ID) +} + +fn verify_offline_for_package( + fixture: &Fixture, + local_file_name: &str, + expected_package_id: &str, +) -> ResultType { + let trusted_keys = [key(fixture.public_key)]; + verify_offline_update_metadata_with_options( + &fixture.metadata, + &fixture.signature, + OfflineVerificationOptions { + requirements: OfflineUpdateMetadataRequirements { + local_file_name, + expected_package_id, + artifact: query(), + }, + trusted_keys: &trusted_keys, + }, + ) +} + +#[test] +fn accepts_valid_signed_offline_artifact() { + let artifact = verify_offline(&valid_fixture(), "rustdesk-1.4.6-x86_64.exe") + .expect("verified offline artifact"); + + assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name); + assert_eq!(123456, artifact.size); + assert_eq!(SHA256, artifact.sha256); + assert_eq!("1.4.6", artifact.version); +} + +#[test] +fn offline_metadata_requires_the_expected_package_identity() { + const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom"; + let mut custom_metadata = metadata(); + custom_metadata["package_id"] = json!(CUSTOM_PACKAGE_ID); + let fixture = sign_fixture(custom_metadata, KEY_ID); + + assert!( + verify_offline_for_package(&fixture, "rustdesk-1.4.6-x86_64.exe", CUSTOM_PACKAGE_ID) + .is_ok() + ); + assert!(verify_offline(&fixture, "rustdesk-1.4.6-x86_64.exe").is_err()); +} + +#[test] +fn accepts_signed_offline_artifact_from_custom_https_release() { + let mut custom_metadata = metadata(); + custom_metadata["artifacts"][0]["url"] = + json!("https://updates.example.com/releases/v1.4.6/rustdesk-1.4.6-x86_64.exe"); + let fixture = sign_fixture(custom_metadata, KEY_ID); + + let artifact = verify_offline(&fixture, "rustdesk-1.4.6-x86_64.exe") + .expect("verified custom HTTPS artifact"); + + assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name); +} + +#[test] +fn rejects_signed_offline_artifact_from_unsafe_url() { + for url in [ + "http://updates.example.com/rustdesk-1.4.6-x86_64.exe", + "https://user@updates.example.com/rustdesk-1.4.6-x86_64.exe", + ] { + let mut invalid_metadata = metadata(); + invalid_metadata["artifacts"][0]["url"] = json!(url); + let fixture = sign_fixture(invalid_metadata, KEY_ID); + + assert!( + verify_offline(&fixture, "rustdesk-1.4.6-x86_64.exe").is_err(), + "{url}" + ); + } +} + +#[test] +fn rejects_offline_artifact_name_or_release_mismatch() { + assert!(verify_offline(&valid_fixture(), "renamed-update.exe").is_err()); + + let mut invalid_metadata = metadata(); + invalid_metadata["version"] = json!("1.4.7"); + assert!(verify_offline( + &sign_fixture(invalid_metadata, KEY_ID), + "rustdesk-1.4.6-x86_64.exe" + ) + .is_err()); +} diff --git a/src/update_metadata_tests.rs b/src/update_metadata_tests.rs new file mode 100644 index 000000000..911388f8d --- /dev/null +++ b/src/update_metadata_tests.rs @@ -0,0 +1,283 @@ +use super::*; +use hbb_common::{ + base64::engine::general_purpose::STANDARD, + sodiumoxide::{self, crypto::sign}, + ResultType, +}; +use serde_json::{json, Value}; + +const KEY_ID: &str = "test-ed25519-main"; +const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +struct Fixture { + metadata: Vec, + signature: Vec, + public_key: [u8; 32], +} + +fn artifact() -> Value { + json!({"platform":"windows","arch":"x86_64","format":"exe","url":"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe","file_name":"rustdesk-1.4.6-x86_64.exe","size":123456,"sha256":SHA256}) +} + +fn metadata() -> Value { + json!({"schema_version":1,"app":"rustdesk","package_id":"rustdesk","version":"1.4.6","release_id":"v1.4.6","published_at":"2026-05-14T00:00:00Z","signature_key_id":KEY_ID,"artifacts":[artifact()]}) +} + +fn set_value(metadata: &mut Value, key: &str, value: Value, artifact_field: bool) { + let target = if artifact_field { + &mut metadata["artifacts"][0] + } else { + metadata + }; + target + .as_object_mut() + .expect("JSON object") + .insert(key.to_string(), value); +} + +fn sign_fixture(metadata: Value, signature_key_id: &str) -> Fixture { + sodiumoxide::init().expect("test sodiumoxide init"); + let metadata = serde_json::to_vec(&metadata).expect("metadata JSON"); + let (public_key, secret_key) = sign::gen_keypair(); + let mut signed = UPDATE_METADATA_SIGNATURE_CONTEXT.to_vec(); + signed.extend_from_slice(&metadata); + let sig = sign::sign_detached(&signed, &secret_key); + let sig_json = json!({"schema_version":1,"algorithm":"ed25519","key_id":signature_key_id,"signature":STANDARD.encode(sig.to_bytes())}); + Fixture { + metadata, + signature: serde_json::to_vec(&sig_json).expect("signature JSON"), + public_key: public_key.0, + } +} + +fn valid_fixture() -> Fixture { + sign_fixture(metadata(), KEY_ID) +} +fn key(public_key: [u8; 32]) -> TrustedUpdateKey { + TrustedUpdateKey { + key_id: KEY_ID, + algorithm: "ed25519", + public_key, + } +} +fn query<'a>() -> UpdateArtifactQuery<'a> { + UpdateArtifactQuery { + platform: "windows", + arch: "x86_64", + format: "exe", + } +} +fn requirements<'a>(artifact: UpdateArtifactQuery<'a>) -> MetadataRequirements<'a> { + MetadataRequirements { + expected_version: "1.4.6", + expected_release_id: "v1.4.6", + artifact, + } +} +fn verify_with_query( + fixture: &Fixture, + query: &UpdateArtifactQuery<'_>, +) -> ResultType { + let trusted_keys = [key(fixture.public_key)]; + verify_update_metadata_with_options( + &fixture.metadata, + &fixture.signature, + VerificationOptions { + requirements: requirements(*query), + expected_package_id: PUBLIC_UPDATE_PACKAGE_ID, + artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", + ), + trusted_keys: &trusted_keys, + }, + ) +} + +fn verify(fixture: &Fixture) -> ResultType { + verify_with_query(fixture, &query()) +} + +fn verify_for_package( + fixture: &Fixture, + expected_package_id: &str, +) -> ResultType { + let trusted_keys = [key(fixture.public_key)]; + verify_update_metadata_with_options( + &fixture.metadata, + &fixture.signature, + VerificationOptions { + requirements: requirements(query()), + expected_package_id, + artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", + ), + trusted_keys: &trusted_keys, + }, + ) +} + +#[test] +fn accepts_valid_metadata_and_signature() { + let artifact = verify(&valid_fixture()).expect("verified artifact"); + assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name); + assert_eq!(123456, artifact.size); + assert_eq!(SHA256, artifact.sha256); + assert_eq!("1.4.6", artifact.version); +} + +#[test] +fn accepts_only_the_expected_package_identity() { + const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom"; + let mut custom_metadata = metadata(); + custom_metadata["package_id"] = json!(CUSTOM_PACKAGE_ID); + let fixture = sign_fixture(custom_metadata, KEY_ID); + + assert!(verify_for_package(&fixture, CUSTOM_PACKAGE_ID).is_ok()); + assert!(verify_for_package(&fixture, "rustdesk").is_err()); +} + +#[test] +fn rejects_bad_signature_or_tampered_metadata() { + let mut fixture = valid_fixture(); + fixture.metadata.push(b' '); + assert!(verify(&fixture).is_err()); + for (key, value) in [ + ("schema_version", json!(2)), + ("algorithm", json!("rsa")), + ("signature", json!("not base64")), + ("signature", json!(STANDARD.encode([1u8; 63]))), + ] { + let mut fixture = valid_fixture(); + let mut sig: Value = serde_json::from_slice(&fixture.signature).expect("signature"); + sig.as_object_mut() + .expect("signature object") + .insert(key.to_string(), value); + fixture.signature = serde_json::to_vec(&sig).expect("signature bytes"); + assert!(verify(&fixture).is_err(), "{}", key); + } +} + +#[test] +fn rejects_schema_policy_artifact_and_url_mismatches() { + let cases = [ + ("schema_version", json!(2), false), ("app", json!("other"), false), + ("version", json!("1.4.7"), false), ("release_id", json!("v1.4.7"), false), + ("package_id", json!("custom"), false), ("signature_key_id", json!("different-key"), false), + ("arch", json!("x86"), true), + ("sha256", json!("not-a-sha256"), true), + ("url", json!("https://github.com/rustdesk/rustdesk/releases/download/v1.4.7/rustdesk-1.4.6-x86_64.exe"), true), + ("url", json!("https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/other.exe"), true), + ]; + for (key, value, artifact_field) in cases { + let mut metadata = metadata(); + set_value(&mut metadata, key, value, artifact_field); + assert!(verify(&sign_fixture(metadata, KEY_ID)).is_err(), "{}", key); + } +} + +#[test] +fn rejects_artifact_url_query_or_fragment() { + for suffix in ["?download=1", "#hash"] { + let mut invalid_metadata = metadata(); + invalid_metadata["artifacts"][0]["url"] = json!(format!( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe{suffix}" + )); + let fixture = sign_fixture(invalid_metadata, KEY_ID); + let error = verify(&fixture).err().expect("invalid artifact URL"); + + assert_eq!( + "update artifact URL must not contain query or fragment", + error.to_string() + ); + } +} + +#[test] +fn rejects_duplicate_artifact_matches() { + let mut duplicate_metadata = metadata(); + duplicate_metadata["artifacts"] + .as_array_mut() + .expect("artifacts") + .push(artifact()); + assert!(verify(&sign_fixture(duplicate_metadata, KEY_ID)).is_err()); +} + +#[test] +fn rejects_artifact_file_name_path_components() { + for file_name in [ + "", + "nested/rustdesk.exe", + r"nested\rustdesk.exe", + "..", + "rust desk.exe", + "rustdesk*.exe", + "rustdesk\n.exe", + "rüstdesk.exe", + ] { + let mut invalid_metadata = metadata(); + invalid_metadata["artifacts"][0]["file_name"] = json!(file_name); + invalid_metadata["artifacts"][0]["url"] = json!(format!( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/{file_name}" + )); + let fixture = sign_fixture(invalid_metadata, KEY_ID); + + let error = verify_with_query(&fixture, &query()) + .err() + .expect("invalid file name"); + + assert_eq!("invalid update artifact file name", error.to_string()); + } +} + +#[test] +fn rejects_path_components_in_unselected_artifact_file_name() { + let mut invalid_metadata = metadata(); + let mut unselected_artifact = artifact(); + unselected_artifact["platform"] = json!("linux"); + unselected_artifact["file_name"] = json!("nested/rustdesk-linux.exe"); + invalid_metadata["artifacts"] + .as_array_mut() + .expect("artifacts") + .push(unselected_artifact); + + let error = verify(&sign_fixture(invalid_metadata, KEY_ID)) + .err() + .expect("invalid file name"); + + assert_eq!("invalid update artifact file name", error.to_string()); +} + +#[test] +fn verifies_metadata_generated_by_python_release_script_fixture() { + const PYTHON_KEY_ID: &str = "python-test-ed25519-main"; + let metadata = br#"{"schema_version":1,"app":"rustdesk","package_id":"rustdesk","version":"1.4.6","release_id":"v1.4.6","published_at":"2026-05-14T00:00:00Z","signature_key_id":"python-test-ed25519-main","artifacts":[{"platform":"windows","arch":"x86_64","format":"exe","url":"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe","file_name":"rustdesk-1.4.6-x86_64.exe","size":8,"sha256":"304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed"}]}"#; + let signature = br#"{"schema_version":1,"algorithm":"ed25519","key_id":"python-test-ed25519-main","signature":"ZMcod9VNaEGTYK0gIfGmMQ44HAvrYAkYDyIL9JTKjzUH+hVFYs8KpvnGSlteAHwqiJuJDdiKBVCPEdtcRRAJCA=="}"#; + let trusted_key = TrustedUpdateKey { + key_id: PYTHON_KEY_ID, + algorithm: "ed25519", + public_key: [ + 3, 161, 7, 191, 243, 206, 16, 190, 29, 112, 221, 24, 231, 75, 192, 153, 103, 228, 214, + 48, 155, 165, 13, 95, 29, 220, 134, 100, 18, 85, 49, 184, + ], + }; + + let trusted_keys = [trusted_key]; + let artifact = verify_update_metadata_with_options( + metadata, + signature, + VerificationOptions { + requirements: requirements(query()), + expected_package_id: PUBLIC_UPDATE_PACKAGE_ID, + artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", + ), + trusted_keys: &trusted_keys, + }, + ) + .expect("Python-generated update metadata verifies"); + + assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name); +} + +#[path = "update_metadata/offline_tests.rs"] +mod offline_tests; diff --git a/src/updater.rs b/src/updater.rs index 0c1784000..533743928 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,9 +1,19 @@ -use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url_strict}; -use hbb_common::{bail, config, log, ResultType}; +mod artifact; +mod download; + +use crate::common::do_check_software_update; +use artifact::verified_update_artifact_from_release_page_url; +pub(crate) use artifact::{current_update_arch, current_update_format, current_update_platform}; +pub use artifact::{ + current_update_artifact_query, get_update_download_file_from_url, + verified_update_artifact_for_download_url, verified_update_artifact_for_release_page_url, +}; use base::config::keys; +use download::ensure_verified_update_artifact; +pub(crate) use download::remove_update_file; +use hbb_common::{bail, config, log, ResultType}; use std::{ - io::Write, - path::{Component, Path, PathBuf}, + path::Path, sync::{ atomic::{AtomicUsize, Ordering}, mpsc::{channel, Receiver, Sender}, @@ -12,6 +22,9 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(target_os = "windows")] +use std::path::PathBuf; + #[cfg(target_os = "macos")] use std::os::{ fd::AsRawFd, @@ -182,91 +195,89 @@ fn check_update(manually: bool) -> ResultType<()> { } #[cfg(target_os = "windows")] let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); + #[cfg(not(target_os = "windows"))] + let update_msi = false; if !(manually || config::Config::get_bool_option(keys::OPTION_ALLOW_AUTO_UPDATE)) { return Ok(()); } - if do_check_software_update().is_err() { - // ignore - return Ok(()); - } + do_check_software_update()?; let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); if update_url.is_empty() { log::debug!("No update available."); } else { - let download_url = update_url.replace("tag", "download"); - let version = download_url.split('/').last().unwrap_or_default(); - #[cfg(target_os = "windows")] - let download_url = if cfg!(feature = "flutter") { - let Some(arch) = crate::platform::windows::release_arch_suffix() else { - bail!( - "Unsupported Windows release architecture: {}", - std::env::consts::ARCH - ); - }; - format!( - "{}/rustdesk-{}-{}.{}", - download_url, - version, - arch, - if update_msi { "msi" } else { "exe" } - ) - } else { - format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) + let update_format = current_update_format(update_msi); + if update_format == "unknown" { + log::debug!("Automatic update is not supported on this platform."); + return Ok(()); + } + let query = crate::update_metadata::UpdateArtifactQuery { + platform: current_update_platform(), + arch: current_update_arch(), + format: update_format, }; + let artifact = verified_update_artifact_from_release_page_url(&update_url, &query)?; + let download_url = artifact.url.as_str(); + #[cfg(target_os = "windows")] + let version = artifact.version.as_str(); + #[cfg(target_os = "windows")] log::debug!("New version available: {}", &version); - let client = create_http_client_with_url_strict(&download_url)?; - let Some(file_path) = get_download_file_from_url(&download_url) else { + let Some(file_path) = get_update_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::().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)?; - } + ensure_verified_update_artifact(download_url, &file_path, artifact.size, &artifact.sha256)?; // We have checked if the `conns` is empty before, but we need to check again. // No need to care about the downloaded file here, because it's rare case that the `conns` are empty // before the download, but not empty after the download. if has_no_active_conns() { #[cfg(target_os = "windows")] - update_new_version(update_msi, &version, &file_path); + update_new_version(update_msi, version, &file_path, &artifact.sha256); + #[cfg(target_os = "macos")] + { + let Some(file_path) = file_path.to_str() else { + bail!("Invalid UTF-8 path: {}", file_path.display()); + }; + crate::platform::macos::update_to_verified_dmg( + file_path, + &artifact.sha256, + Some(artifact.size), + )?; + } } } 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::copy_and_verify_update_file_sha256(p, expected_sha256) + { + Ok(update_file) => update_file, + Err(e) => { + log::error!("Refusing to update from invalid {}: {}", kind, e); + remove_update_file(file_path); + 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); + update_file.cleanup(); + remove_update_file(file_path); + 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() @@ -274,7 +285,14 @@ 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; + }; + let result = crate::platform::update_me_msi(&update_path, expected_sha256, true); + update_file.cleanup(); + match result { Ok(_) => { log::debug!("New version \"{}\" updated.", version); } @@ -284,10 +302,15 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { version, e ); - std::fs::remove_file(&file_path).ok(); + remove_update_file(file_path); } } } 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(); @@ -298,7 +321,8 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { "Failed to handle custom client staging dir before update: {}", e ); - std::fs::remove_file(&file_path).ok(); + update_file.cleanup(); + remove_update_file(file_path); return; } Some(custom_client_staging_dir) @@ -312,7 +336,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() { @@ -320,6 +344,9 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { false } else { log::debug!("New version \"{}\" is launched.", version); + unsafe { + winapi::um::handleapi::CloseHandle(h); + } true } } @@ -334,7 +361,8 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { &dir )); } - std::fs::remove_file(&file_path).ok(); + update_file.cleanup(); + remove_update_file(file_path); } } } else { @@ -342,7 +370,7 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { "Failed to get the current process session id, Error {}", std::io::Error::last_os_error() ); - std::fs::remove_file(&file_path).ok(); + remove_update_file(file_path); } } else { // unreachable!() @@ -353,63 +381,6 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { } } -pub fn get_update_download_file_from_url(url: &str) -> Option { - let parsed = url::Url::parse(url).ok()?; - // Check the raw prefix before Url normalizes default ports. - if !url.starts_with("https://github.com/") - || parsed.scheme() != "https" - || parsed.host_str() != Some("github.com") - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.port().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return None; - } - - let mut segments = parsed.path_segments()?; - let owner = segments.next()?; - let repo = segments.next()?; - let releases = segments.next()?; - let download = segments.next()?; - let tag = segments.next()?; - let filename = segments.next()?; - - if owner != "rustdesk" - || repo != "rustdesk" - || releases != "releases" - || download != "download" - || tag.is_empty() - || segments.next().is_some() - || !is_plain_update_filename(filename) - { - return None; - } - - Some(std::env::temp_dir().join(filename)) -} - -fn is_plain_update_filename(filename: &str) -> bool { - if filename.is_empty() - || filename.contains('/') - || filename.contains('\\') - || filename.contains(':') - { - return false; - } - - let mut components = Path::new(filename).components(); - matches!( - components.next(), - Some(Component::Normal(name)) if name.to_str() == Some(filename) - ) && components.next().is_none() -} - -pub fn get_download_file_from_url(url: &str) -> Option { - get_update_download_file_from_url(url) -} - /// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) /// from every logged-in user's --server process via IPC. /// The root service cannot read connection state directly since connections @@ -419,39 +390,47 @@ pub fn get_download_file_from_url(url: &str) -> Option { /// an unknown session state. #[cfg(target_os = "macos")] pub fn has_no_active_conns_ipc() -> bool { - let rt = match hbb_common::tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(_) => return false, + let result = match hbb_common::tokio::runtime::Runtime::new() { + Ok(rt) => rt.block_on(query_no_active_conns_ipc()), + Err(err) => Err(hbb_common::anyhow::anyhow!( + "failed to create IPC runtime: {err}" + )), }; - rt.block_on(async { - // Use the same GUI-domain-filtered UID set as the update script. - // Shell-only SSH/TTY users are excluded, while an empty GUI set maps - // to UID 0 so the LoginWindow server is queried rather than assumed idle. - let uids = crate::platform::get_logged_in_uids(); - // Check each user's server — fail closed if any has active connections - for uid in uids { - if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await { - if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() { - match conn.next_timeout(1000).await { - Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => { - // Explicit no active connections — safe to continue - } - Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => { - return false; // Explicit active connections - } - _ => { - return false; // Timeout/error/unexpected — fail closed - } - } - } else { - return false; // Send failed — fail closed - } - } else { - return false; // Connection failed — fail closed - } + match result { + Ok(no_active_conns) => no_active_conns, + Err(err) => { + log::warn!( + "[root-update] Unable to determine active connection state; deferring update: {}", + err + ); + false } - true // All users explicitly confirmed no active connections - }) + } +} + +#[cfg(target_os = "macos")] +async fn query_no_active_conns_ipc() -> ResultType { + const IPC_TIMEOUT_MS: u64 = 1_000; + // An empty GUI user set maps to UID 0 so LoginWindow must also confirm it is idle. + let uids = crate::platform::get_logged_in_uids(); + for uid in uids { + let mut conn = crate::ipc::connect_for_uid(IPC_TIMEOUT_MS, uid, "") + .await + .map_err(|err| { + hbb_common::anyhow::anyhow!("IPC connection failed for uid {uid}: {err}") + })?; + conn.send(&crate::ipc::Data::HasNoActiveConns(None)) + .await + .map_err(|err| hbb_common::anyhow::anyhow!("IPC send failed for uid {uid}: {err}"))?; + match conn.next_timeout(IPC_TIMEOUT_MS).await { + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => {} + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => return Ok(false), + Ok(Some(_)) => bail!("unexpected active-connection IPC response for uid {uid}"), + Ok(None) => bail!("active-connection IPC closed for uid {uid}"), + Err(err) => bail!("active-connection IPC failed for uid {uid}: {err}"), + } + } + Ok(true) } #[cfg(target_os = "macos")] @@ -586,17 +565,19 @@ pub fn check_update_as_root() -> ResultType { log::info!("[root-update] No update available."); return Ok(false); } - let download_url = update_url.replace("tag", "download"); - let version = download_url.split('/').last().unwrap_or_default().to_string(); - let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" }; - let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch); - log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url); + let query = current_update_artifact_query(false); + let artifact = verified_update_artifact_from_release_page_url(&update_url, &query)?; + let dmg_url = artifact.url.as_str(); + log::info!( + "[root-update] New version: {}, downloading from {}", + artifact.version, + dmg_url + ); // Validate URL against GitHub release allowlist before downloading as root - let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else { + let Some(file_path_validated) = get_update_download_file_from_url(dmg_url) else { bail!("[root-update] URL failed allowlist check: {}", dmg_url); }; drop(file_path_validated); - let client = create_http_client_with_url_strict(&dmg_url)?; // Use mktemp so a local user cannot pre-create a predictable path and // permanently deny updates for a reused service PID. let private_tmp_output = std::process::Command::new("/usr/bin/mktemp") @@ -619,26 +600,21 @@ pub fn check_update_as_root() -> ResultType { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?; } - let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg"); - let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename)); + let file_path = Path::new(&private_tmp).join(&artifact.file_name); let tmp_path = file_path.to_string_lossy().to_string(); - // Download - let mut response = client.get(&dmg_url).send()?; - if !response.status().is_success() { - let _ = std::fs::remove_dir_all(&private_tmp); - bail!("[root-update] Failed to download: {}", response.status()); - } - // Create file exclusively (O_EXCL) and stream response directly into it + if let Err(err) = + ensure_verified_update_artifact(dmg_url, &file_path, artifact.size, &artifact.sha256) { - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&file_path) - .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; - std::io::copy(&mut response, &mut file) - .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + if let Err(cleanup_err) = std::fs::remove_dir_all(&private_tmp) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + private_tmp, + cleanup_err + ); + } + return Err(err); } - log::info!("[root-update] Downloaded to {}", tmp_path); + log::info!("[root-update] Downloaded and verified at {}", tmp_path); // Recheck active sessions before installing — download can take minutes if !has_no_active_conns_ipc() { if let Err(e) = std::fs::remove_dir_all(&private_tmp) { @@ -647,47 +623,14 @@ pub fn check_update_as_root() -> ResultType { bail!("[root-update] Active session started during download, deferring update."); } // Install silently as root - let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version); + let result = crate::platform::update_from_dmg_as_root(&tmp_path, &artifact.version); // Clean up download directory if let Err(e) = std::fs::remove_dir_all(&private_tmp) { - log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + private_tmp, + e + ); } result.map(|_| true) } - -#[cfg(test)] -mod tests { - use super::get_download_file_from_url; - - #[test] - fn update_download_file_accepts_expected_github_asset_urls() { - let file = get_download_file_from_url( - "https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg", - ) - .expect("valid GitHub release asset URL"); - - assert_eq!( - file.file_name().and_then(|name| name.to_str()), - Some("rustdesk-1.4.0-x86_64.dmg") - ); - } - - #[test] - fn update_download_file_rejects_untrusted_or_malformed_urls() { - for url in [ - "http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", - "https://example.com/rustdesk.exe", - "https://github.com/other/project/releases/download/1/rustdesk.exe", - "https://github.com/rustdesk/rustdesk/releases/download/1/", - "https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe", - "https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe", - "https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", - "https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe", - "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1", - "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download", - "not a url", - ] { - assert!(get_download_file_from_url(url).is_none(), "{url}"); - } - } -} diff --git a/src/updater/artifact.rs b/src/updater/artifact.rs new file mode 100644 index 000000000..9759b1f66 --- /dev/null +++ b/src/updater/artifact.rs @@ -0,0 +1,259 @@ +use crate::{ + common::{display_version_from_release_id, release_id_from_update_url}, + hbbs_http::create_http_client_with_url_strict, + update_metadata::{UpdateArtifactQuery, UpdateMetadataRequirements, VerifiedUpdateArtifact}, +}; +use hbb_common::{bail, ResultType}; +use std::{ + io::Read, + path::{Component, Path, PathBuf}, + sync::Mutex, + time::Duration, +}; + +const UPDATE_SIDECAR_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const UPDATE_METADATA_SIDECAR_MAX_BYTES: u64 = 1024 * 1024; + +lazy_static::lazy_static! { + static ref VERIFIED_UPDATE_ARTIFACT: Mutex> = Mutex::new(None); +} + +pub(crate) fn current_update_platform() -> &'static str { + #[cfg(target_os = "windows")] + { + "windows" + } + #[cfg(target_os = "macos")] + { + "macos" + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + std::env::consts::OS + } +} + +pub(crate) fn current_update_arch() -> &'static str { + #[cfg(all(target_os = "windows", not(feature = "flutter")))] + { + "x86" + } + #[cfg(not(all(target_os = "windows", not(feature = "flutter"))))] + { + std::env::consts::ARCH + } +} + +pub(crate) fn current_update_format(update_msi: bool) -> &'static str { + #[cfg(any( + not(target_os = "windows"), + all(target_os = "windows", not(feature = "flutter")) + ))] + let _ = update_msi; + #[cfg(all(target_os = "windows", feature = "flutter"))] + { + if update_msi { + return "msi"; + } + "exe" + } + #[cfg(all(target_os = "windows", not(feature = "flutter")))] + { + "exe" + } + #[cfg(target_os = "macos")] + { + "dmg" + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + "unknown" + } +} + +pub fn current_update_artifact_query(update_msi: bool) -> UpdateArtifactQuery<'static> { + UpdateArtifactQuery { + platform: current_update_platform(), + arch: current_update_arch(), + format: current_update_format(update_msi), + } +} + +pub fn verified_update_artifact_for_release_page_url( + release_page_url: &str, + query: UpdateArtifactQuery<'_>, +) -> ResultType { + let artifact = verified_update_artifact_from_release_page_url(release_page_url, &query)?; + *VERIFIED_UPDATE_ARTIFACT.lock().unwrap() = Some(artifact.clone()); + Ok(artifact) +} + +pub fn verified_update_artifact_for_download_url( + download_url: &str, +) -> ResultType { + VERIFIED_UPDATE_ARTIFACT + .lock() + .unwrap() + .as_ref() + .filter(|artifact| artifact.url == download_url) + .cloned() + .ok_or_else(|| hbb_common::anyhow::anyhow!("update artifact was not verified")) +} + +pub(super) fn verified_update_artifact_from_release_page_url( + update_url: &str, + query: &UpdateArtifactQuery<'_>, +) -> ResultType { + let release_id = release_id_from_update_url(update_url)?; + let display_version = display_version_from_release_id(&release_id)?; + let expected_artifact_url_prefix = + format!("https://github.com/rustdesk/rustdesk/releases/download/{release_id}/"); + let metadata_url = format!("{expected_artifact_url_prefix}rustdesk-update.json"); + let signature_url = format!("{metadata_url}.sig"); + let metadata_bytes = fetch_update_sidecar_bytes(&metadata_url)?; + let signature_bytes = fetch_update_sidecar_bytes(&signature_url)?; + let requirements = UpdateMetadataRequirements { + expected_version: display_version.as_str(), + expected_release_id: release_id.as_str(), + expected_artifact_url_prefix: expected_artifact_url_prefix.as_str(), + artifact: *query, + }; + crate::update_metadata::verify_update_metadata(&metadata_bytes, &signature_bytes, requirements) +} + +fn fetch_update_sidecar_bytes(url: &str) -> ResultType> { + let client = create_http_client_with_url_strict(url)?; + let mut response = client + .get(url) + .timeout(UPDATE_SIDECAR_HTTP_REQUEST_TIMEOUT) + .send()?; + if !response.status().is_success() { + bail!( + "Failed to download update metadata sidecar: {}", + response.status() + ); + } + read_limited_response_bytes( + &mut response, + UPDATE_METADATA_SIDECAR_MAX_BYTES, + "Update metadata sidecar", + ) +} + +fn read_limited_response_bytes( + reader: &mut R, + limit: u64, + what: &str, +) -> ResultType> { + let mut limited_reader = reader.take(limit.saturating_add(1)); + let mut bytes = Vec::new(); + limited_reader.read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + bail!("{what} exceeds maximum allowed size of {limit} bytes"); + } + Ok(bytes) +} + +pub fn get_update_download_file_from_url(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + // Check the raw prefix before Url normalizes default ports. + if !url.starts_with("https://github.com/") + || parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + + let mut segments = parsed.path_segments()?; + let owner = segments.next()?; + let repo = segments.next()?; + let releases = segments.next()?; + let download = segments.next()?; + let tag = segments.next()?; + let filename = segments.next()?; + + if owner != "rustdesk" + || repo != "rustdesk" + || releases != "releases" + || download != "download" + || tag.is_empty() + || segments.next().is_some() + || !is_plain_update_filename(filename) + { + return None; + } + + Some(std::env::temp_dir().join(filename)) +} + +fn is_plain_update_filename(filename: &str) -> bool { + if filename.is_empty() + || filename.contains('/') + || filename.contains('\\') + || filename.contains(':') + { + return false; + } + + let mut components = Path::new(filename).components(); + matches!( + components.next(), + Some(Component::Normal(name)) if name.to_str() == Some(filename) + ) && components.next().is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_download_file_accepts_expected_github_asset_urls() { + let file = get_update_download_file_from_url( + "https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg", + ) + .expect("valid GitHub release asset URL"); + + assert_eq!( + file.file_name().and_then(|name| name.to_str()), + Some("rustdesk-1.4.0-x86_64.dmg") + ); + } + + #[test] + fn update_download_file_rejects_untrusted_or_malformed_urls() { + for url in [ + "http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://example.com/rustdesk.exe", + "https://github.com/other/project/releases/download/1/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/", + "https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe", + "https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1", + "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download", + "not a url", + ] { + assert!(get_update_download_file_from_url(url).is_none(), "{url}"); + } + } + + #[test] + fn limited_sidecar_reader_rejects_oversized_payloads() { + let mut payload: &[u8] = b"rustdesk"; + assert_eq!( + read_limited_response_bytes(&mut payload, 8, "sidecar") + .unwrap() + .len(), + 8 + ); + + let mut oversized: &[u8] = b"too-large"; + assert!(read_limited_response_bytes(&mut oversized, 4, "sidecar").is_err()); + } +} diff --git a/src/updater/download.rs b/src/updater/download.rs new file mode 100644 index 000000000..0aaaadb6a --- /dev/null +++ b/src/updater/download.rs @@ -0,0 +1,227 @@ +use crate::hbbs_http::create_http_client_with_url_strict; +use hbb_common::{bail, log, ResultType}; +use std::{ + io::{Read, Seek, Write}, + path::{Path, PathBuf}, +}; + +const UPDATE_FILE_CREATE_ATTEMPTS: usize = 16; + +pub(super) fn ensure_verified_update_artifact( + download_url: &str, + file_path: &Path, + expected_size: u64, + expected_sha256: &str, +) -> ResultType<()> { + if let Some(file_size) = cached_update_artifact_size(file_path)? { + if file_size == expected_size { + match verify_file_sha256(file_path, expected_sha256) { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("Removing cached update file with invalid SHA256: {}", e); + remove_cached_update_artifact(file_path)?; + } + } + } else { + log::warn!( + "Removing cached update file with size mismatch for {}: expected {}, got {}", + file_path.display(), + expected_size, + file_size + ); + remove_cached_update_artifact(file_path)?; + } + } + let client = create_http_client_with_url_strict(download_url)?; + let response = client.get(download_url).send()?; + if !response.status().is_success() { + bail!( + "Failed to download the new version file: {}", + response.status() + ); + } + let mut limited_response = response.take(expected_size.saturating_add(1)); + write_verified_update_artifact( + file_path, + &mut limited_response, + expected_size, + expected_sha256, + ) +} + +fn cached_update_artifact_size(file_path: &Path) -> ResultType> { + let metadata = match std::fs::symlink_metadata(file_path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + if metadata.file_type().is_file() { + return Ok(Some(metadata.len())); + } + if metadata.file_type().is_symlink() { + log::warn!("Removing cached update symlink: {}", file_path.display()); + remove_cached_update_artifact(file_path)?; + return Ok(None); + } + bail!( + "Refusing to use update cache path that is not a regular file: {}", + file_path.display() + ) +} + +fn remove_cached_update_artifact(file_path: &Path) -> ResultType<()> { + let metadata = match std::fs::symlink_metadata(file_path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let file_type = metadata.file_type(); + if file_type.is_file() { + std::fs::remove_file(file_path)?; + } else if file_type.is_symlink() { + #[cfg(target_os = "windows")] + { + use std::os::windows::fs::FileTypeExt; + if file_type.is_symlink_dir() { + std::fs::remove_dir(file_path)?; + } else { + std::fs::remove_file(file_path)?; + } + } + #[cfg(not(target_os = "windows"))] + std::fs::remove_file(file_path)?; + } else { + bail!( + "Refusing to remove update cache path that is not a file: {}", + file_path.display() + ); + } + Ok(()) +} + +pub(crate) fn remove_update_file(file_path: &Path) { + match std::fs::remove_file(file_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => log::warn!( + "Failed to remove update file {}: {}", + file_path.display(), + e + ), + } +} + +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..UPDATE_FILE_CREATE_ATTEMPTS { + let temp_path = download_dir.join(format!( + ".{}.{}.{}.download", + file_name, + std::process::id(), + hbb_common::rand::random::() + )); + match std::fs::OpenOptions::new() + .read(true) + .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 write_verified_update_artifact( + final_path: &Path, + reader: &mut R, + expected_size: u64, + expected_sha256: &str, +) -> ResultType<()> { + let (mut file, temp_path) = create_download_temp_file(final_path)?; + if let Err(e) = copy_and_verify_update_artifact( + &mut file, + &temp_path, + reader, + expected_size, + expected_sha256, + ) { + remove_update_file(&temp_path); + return Err(e); + } + drop(file); + if let Err(e) = remove_cached_update_artifact(final_path) { + remove_update_file(&temp_path); + return Err(e); + } + if let Err(e) = std::fs::rename(&temp_path, final_path) { + remove_update_file(&temp_path); + return Err(e.into()); + } + Ok(()) +} + +fn copy_and_verify_update_artifact( + file: &mut std::fs::File, + temp_path: &Path, + reader: &mut R, + expected_size: u64, + expected_sha256: &str, +) -> ResultType<()> { + let bytes_written = std::io::copy(reader, file)?; + file.flush()?; + if bytes_written != expected_size { + bail!( + "Update artifact size mismatch for {}: expected {}, got {}", + temp_path.display(), + expected_size, + bytes_written + ); + } + verify_update_file_sha256(file, temp_path, expected_sha256) +} + +fn verify_file_sha256(path: &Path, expected_sha256: &str) -> ResultType<()> { + let mut file = std::fs::File::open(path)?; + verify_update_file_sha256(&mut file, path, expected_sha256) +} + +fn verify_update_file_sha256( + reader: &mut R, + path: &Path, + expected_sha256: &str, +) -> ResultType<()> { + use crate::update_hash::{verify_sha256_reader, Sha256VerificationError}; + + match verify_sha256_reader(reader, expected_sha256) { + Ok(()) => Ok(()), + Err(Sha256VerificationError::InvalidExpected) => bail!( + "Expected update file SHA256 is malformed for {}", + path.display() + ), + Err(Sha256VerificationError::Mismatch { + expected_sha256, + actual_sha256, + }) => bail!( + "SHA256 mismatch for {}: expected {}, got {}", + path.display(), + expected_sha256, + actual_sha256 + ), + Err(Sha256VerificationError::Io(err)) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/updater/download/tests.rs b/src/updater/download/tests.rs new file mode 100644 index 000000000..661c51153 --- /dev/null +++ b/src/updater/download/tests.rs @@ -0,0 +1,83 @@ +use super::*; + +#[test] +fn copy_and_verify_update_artifact_hashes_open_file_handle() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-open-handle-sha256-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 file, _) = create_download_temp_file(&final_path).unwrap(); + let display_path = test_dir.join("path-must-not-be-opened.download"); + let mut data: &[u8] = b"rustdesk"; + + let result = copy_and_verify_update_artifact( + &mut file, + &display_path, + &mut data, + 8, + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + ); + let position = result + .as_ref() + .ok() + .map(|_| std::io::Seek::stream_position(&mut file).unwrap()); + drop(file); + std::fs::remove_dir_all(&test_dir).unwrap(); + + assert!(result.is_ok(), "{:?}", result.err()); + assert_eq!(position, Some(0)); +} + +#[test] +fn remove_cached_update_artifact_rejects_directory() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-cache-dir-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let cache_path = test_dir.join("rustdesk-update.exe"); + std::fs::create_dir(&cache_path).unwrap(); + std::fs::write(cache_path.join("stale"), b"stale").unwrap(); + + let result = remove_cached_update_artifact(&cache_path); + + assert!(result.is_err()); + assert!(cache_path.exists()); + std::fs::remove_dir_all(&test_dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn 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 victim_path = test_dir.join("victim"); + std::fs::write(&victim_path, b"victim").unwrap(); + std::os::unix::fs::symlink(&victim_path, &final_path).unwrap(); + let mut data: &[u8] = b"update"; + + write_verified_update_artifact( + &final_path, + &mut data, + 6, + "2937013f2181810606b2a799b05bda2849f3e369a20982a4138f0e0a55984ce4", + ) + .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(); +}