diff --git a/Furious/Backends/Xray/AssetDownloadManager.py b/Furious/Backends/Xray/AssetDownloadManager.py index 9dadd854..4d1800df 100644 --- a/Furious/Backends/Xray/AssetDownloadManager.py +++ b/Furious/Backends/Xray/AssetDownloadManager.py @@ -27,6 +27,7 @@ from PySide6 import QtCore from typing import AnyStr, Union, Callable import os +import re import logging import hashlib import functools @@ -74,25 +75,37 @@ class XrayAssetSHA256DownloadManager(HttpGetManager): return b'' + @staticmethod + def parseDigest(data) -> str: + """Return one normalized SHA-256 token or an empty string.""" + if isinstance(data, bytes): + text = data.decode('ascii', 'replace') + else: + text = str(data) + + fields = text.split() + + if not fields or re.fullmatch(r'[0-9a-fA-F]{64}', fields[0]) is None: + return '' + + return fields[0].lower() + def successCallback(self, networkReply, **kwargs): """Handle a successful network operation.""" filepath = kwargs.pop('filepath', '') downloadCallback = kwargs.pop('downloadCallback', None) - data = networkReply.readAll().data() - - if isinstance(data, bytes): - datastr = data.decode('utf-8', 'replace') - else: - datastr = str(data) - - logger.debug(f'data: {data}') - # 6068a73edfa08b63080b8d362bd4c5b069689a3548f413f43cfa58227a201571 geosite.dat # 3a12beaa33c81b6751b45833a0a942b9462420d91182e7fa1d768b418e604049 geoip.dat - value = datastr.split()[0] - basename = os.path.basename(filepath) + value = self.parseDigest(networkReply.readAll().data()) + + if not value: + logger.error( + f'invalid SHA-256 metadata for {basename}; asset update skipped' + ) + + return def handleFinished(_digest, _value=''): """Handle finished.""" @@ -104,7 +117,7 @@ class XrayAssetSHA256DownloadManager(HttpGetManager): logger.info(f'digest not equal for {basename}. Start downloading asset') if callable(downloadCallback): - downloadCallback() + downloadCallback(_value) else: logger.info(f'digest equal for {basename}. Nothing to do') @@ -115,7 +128,7 @@ class XrayAssetSHA256DownloadManager(HttpGetManager): AppThreadPool().start(worker) - def download(self, url, filepath, downloadCallback: Callable[[], None]): + def download(self, url, filepath, downloadCallback: Callable[[str], None]): """Download the Xray asset SHA-256 download manager.""" self.webGET( url, @@ -136,17 +149,38 @@ class XrayAssetAssetsDownloadManager(HttpGetManager): def successCallback(self, networkReply, **kwargs): """Handle a successful network operation.""" filepath = kwargs.pop('filepath', '') + expectedDigest = str(kwargs.pop('expectedDigest', '')).lower() + + data = bytes(networkReply.readAll().data()) + actualDigest = hashlib.sha256(data).hexdigest() + basename = os.path.basename(filepath) + + if not expectedDigest or actualDigest != expectedDigest: + logger.error( + f'downloaded asset digest mismatch for {basename}; ' + f'existing file preserved' + ) + + return saveFile = QtCore.QSaveFile(filepath) - data = networkReply.readAll().data() - if not saveFile.open(QtCore.QSaveFile.OpenModeFlag.WriteOnly): logger.error( f'failed to open \'{filepath}\' for writing. {saveFile.errorString()}' ) else: - saveFile.write(data) + written = saveFile.write(data) + + if written != len(data): + saveFile.cancelWriting() + + logger.error( + f'write asset to \'{filepath}\' failed. ' + f'{saveFile.errorString()}' + ) + + return if not saveFile.commit(): logger.error( @@ -155,9 +189,13 @@ class XrayAssetAssetsDownloadManager(HttpGetManager): else: logger.info(f'save file to \'{filepath}\' success') - def download(self, url, filepath): + def download(self, url, filepath, expectedDigest: str): """Download the Xray asset assets download manager.""" - self.webGET(url, filepath=str(filepath)) + self.webGET( + url, + filepath=str(filepath), + expectedDigest=expectedDigest, + ) class XrayAssetPairDownloadHelper: diff --git a/tests/README.md b/tests/README.md index c8d3aff6..4ca58c1f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -22,6 +22,7 @@ clients, or real proxy cores. | Subscription workflow, timers, stale requests, and reconciliation | `test_subscription_manager.py`, `test_subscription_sync.py` | | External process launch, output, shutdown, threads, TUN metadata | `test_external_core.py` | | Backend structured-editor observational load and unknown-value preservation | `test_backend_editor_contract.py` | +| Xray asset checksum validation and atomic replacement | `test_xray_asset_download.py` | | Xray/Hysteria2 native-TUN ownership and proxy-only stripping | `test_native_tun_semantics.py` | | Rolling metrics, stable buckets, lazy rendering, and hover | `test_metrics_behavior.py` | | Proxy-only endpoint discovery, caching, and presentation | `test_endpoint_info.py` | @@ -80,7 +81,7 @@ Then run the desired test tier. python -m unittest discover -s tests -v # Regular logic, persistence, plugin, controller, codec, and UI regressions -python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_stylesheet_states tests.test_public_api -v +python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_stylesheet_states tests.test_public_api -v # Direct Qt/process integration and destruction/lifetime checks python -m unittest tests.test_application_process tests.test_external_core tests.test_layout_matrix tests.test_qt_lifetime -v @@ -97,7 +98,7 @@ python -m unittest tests.test_very_heavy -v python -m unittest tests.test_log_manager_generation.VeryHeavyGenerationLogManagerTest -v # Shared-state order-independence spot check -python -m unittest tests.test_public_api tests.test_stylesheet_states tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v +python -m unittest tests.test_public_api tests.test_stylesheet_states tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v python -m unittest discover -s tests -v ``` diff --git a/tests/test_xray_asset_download.py b/tests/test_xray_asset_download.py new file mode 100644 index 00000000..84022026 --- /dev/null +++ b/tests/test_xray_asset_download.py @@ -0,0 +1,154 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Protect Xray asset downloads from malformed or mismatched content.""" + +from Furious.Backends.Xray.AssetDownloadManager import ( + XrayAssetAssetsDownloadManager, + XrayAssetSHA256DownloadManager, +) + +from PySide6 import QtCore + +from unittest import TestCase, mock + +import os +import hashlib +import unittest +import tempfile + +from tests.support import application, processQtEvents + + +class _Reply: + """Expose the byte-array portion of QNetworkReply used by the managers.""" + + def __init__(self, data: bytes): + self._data = QtCore.QByteArray(data) + + def readAll(self): + return self._data + + +class XrayAssetDownloadTest(TestCase): + """Verify checksum metadata and downloaded bytes before replacement.""" + + @classmethod + def setUpClass(cls): + application() + + def tearDown(self): + processQtEvents() + + def testMalformedChecksumDoesNotStartHashOrAssetDownload(self): + manager = XrayAssetSHA256DownloadManager() + download = mock.Mock() + + with ( + mock.patch( + 'Furious.Backends.Xray.AssetDownloadManager.AppThreadPool' + ) as threadPool, + self.assertLogs( + 'Furious.Backends.Xray.AssetDownloadManager', level='ERROR' + ) as logs, + ): + manager.successCallback( + _Reply(b'not-a-sha256 asset.dat'), + filepath='asset.dat', + downloadCallback=download, + ) + + threadPool.assert_not_called() + download.assert_not_called() + self.assertIn('asset update skipped', '\n'.join(logs.output)) + manager.deleteLater() + + def testChangedLocalAssetForwardsNormalizedExpectedDigest(self): + manager = XrayAssetSHA256DownloadManager() + download = mock.Mock() + expectedDigest = hashlib.sha256(b'new asset').hexdigest() + pool = mock.Mock() + pool.start.side_effect = lambda worker: worker.run() + + with ( + tempfile.NamedTemporaryFile() as existing, + mock.patch( + 'Furious.Backends.Xray.AssetDownloadManager.AppThreadPool', + return_value=pool, + ), + ): + existing.write(b'old asset') + existing.flush() + manager.successCallback( + _Reply(f'{expectedDigest.upper()} asset.dat\n'.encode()), + filepath=existing.name, + downloadCallback=download, + ) + + download.assert_called_once_with(expectedDigest) + manager.deleteLater() + + def testMismatchedDownloadPreservesExistingAsset(self): + manager = XrayAssetAssetsDownloadManager() + expectedDigest = hashlib.sha256(b'expected asset').hexdigest() + + with tempfile.NamedTemporaryFile(delete=False) as existing: + existing.write(b'known-good asset') + filepath = existing.name + + self.addCleanup(os.unlink, filepath) + + with self.assertLogs( + 'Furious.Backends.Xray.AssetDownloadManager', level='ERROR' + ) as logs: + manager.successCallback( + _Reply(b'corrupt download'), + filepath=filepath, + expectedDigest=expectedDigest, + ) + + with open(filepath, 'rb') as existing: + self.assertEqual(existing.read(), b'known-good asset') + + self.assertIn('existing file preserved', '\n'.join(logs.output)) + manager.deleteLater() + + def testMatchingDownloadAtomicallyReplacesExistingAsset(self): + manager = XrayAssetAssetsDownloadManager() + downloaded = b'verified new asset' + expectedDigest = hashlib.sha256(downloaded).hexdigest() + + with tempfile.NamedTemporaryFile(delete=False) as existing: + existing.write(b'old asset') + filepath = existing.name + + self.addCleanup(os.unlink, filepath) + + manager.successCallback( + _Reply(downloaded), + filepath=filepath, + expectedDigest=expectedDigest, + ) + + with open(filepath, 'rb') as existing: + self.assertEqual(existing.read(), downloaded) + + manager.deleteLater() + + +if __name__ == '__main__': + unittest.main()