mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-11 00:38:24 +03:00
fix: verify Xray asset downloads before replacement
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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:
|
||||
|
||||
+3
-2
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""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()
|
||||
Reference in New Issue
Block a user