fix: verify Xray asset downloads before replacement

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-28 09:21:52 +08:00
parent aa3216f32a
commit a178172a91
3 changed files with 213 additions and 20 deletions
+56 -18
View File
@@ -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
View File
@@ -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
```
+154
View File
@@ -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()