diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 4466601..b3c2ecb 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -4,7 +4,6 @@ TRANSLATION = { "Furious.Backends.Xray.AssetWindow", "Furious.Backends.Xray.RoutingWindow", "Furious.Widget.ServerTableView", - "Furious.Widget.SubscriptionTableView", "Furious.Window.SubscriptionPage" ], "RU": "Удалить", @@ -394,7 +393,6 @@ TRANSLATION = { }, "Invalid server configuration": { "source": [ - "Furious.Controllers.ConnectionController", "Furious.Window.TextEditorWindow" ], "RU": "Неверная конфигурация сервера", @@ -846,22 +844,6 @@ TRANSLATION = { "ZH": "未知错误", "isReviewed": "True" }, - "Failed to start core": { - "source": [ - "Furious.Controllers.ConnectionController" - ], - "RU": "Сбой при запуске ядра", - "ZH": "内核启动失败", - "isReviewed": "True" - }, - "Core terminated unexpectedly": { - "source": [ - "Furious.Controllers.ConnectionController" - ], - "RU": "Неожиданное завершение работы ядра", - "ZH": "内核意外终止", - "isReviewed": "True" - }, "Disconnected": { "source": [ "Furious.Controllers.ConnectionController", @@ -3317,5 +3299,13 @@ TRANSLATION = { "RU": "Ошибка обновления", "ZH": "更新失败", "isReviewed": "True" + }, + "Usage / Expiry": { + "source": [ + "Furious.Widget.SubscriptionTableView" + ], + "RU": "Трафик / Срок", + "ZH": "用量 / 到期", + "isReviewed": "True" } } diff --git a/Furious/Repository/Subscriptions.py b/Furious/Repository/Subscriptions.py index 5878414..f8a5b4b 100644 --- a/Furious/Repository/Subscriptions.py +++ b/Furious/Repository/Subscriptions.py @@ -54,6 +54,10 @@ class SubscriptionGroup: lastSyncStatus: str = '' lastSyncError: str = '' profileCount: int = 0 + subscriptionUpload: int = 0 + subscriptionDownload: int = 0 + subscriptionTotal: int = 0 + subscriptionExpire: int = 0 extras: dict[str, Any] = field(default_factory=dict, repr=False) @classmethod @@ -77,6 +81,10 @@ class SubscriptionGroup: ('lastSyncStatus', ''), ('lastSyncError', ''), ('profileCount', 0), + ('subscriptionUpload', 0), + ('subscriptionDownload', 0), + ('subscriptionTotal', 0), + ('subscriptionExpire', 0), ) } @@ -110,6 +118,17 @@ class SubscriptionGroup: except (TypeError, ValueError): known[name] = 0 + for name in ( + 'subscriptionUpload', + 'subscriptionDownload', + 'subscriptionTotal', + 'subscriptionExpire', + ): + try: + known[name] = min(max(0, int(known[name])), (1 << 63) - 1) + except (TypeError, ValueError): + known[name] = 0 + extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {} extras.update(data) @@ -133,6 +152,10 @@ class SubscriptionGroup: 'lastSyncStatus': self.lastSyncStatus, 'lastSyncError': self.lastSyncError, 'profileCount': self.profileCount, + 'subscriptionUpload': self.subscriptionUpload, + 'subscriptionDownload': self.subscriptionDownload, + 'subscriptionTotal': self.subscriptionTotal, + 'subscriptionExpire': self.subscriptionExpire, } ) diff --git a/Furious/Service/AGENTS.md b/Furious/Service/AGENTS.md index 45775c8..adbf5c7 100644 --- a/Furious/Service/AGENTS.md +++ b/Furious/Service/AGENTS.md @@ -34,6 +34,9 @@ and temporary resources, never durable collections, shared transition authority, data. Workers never read live repositories or Qt models; the GUI thread verifies the full source signature and group revision, commits while preserving live profile identity/local metadata, then publishes coalesced status/structure. Post-commit reconnect/test invalidation failure is reported without undoing the committed profiles. +- Provider-reported subscription usage/expiry metadata is untrusted advisory input. Parse it with strict bounds at the + network boundary and commit or clear it only alongside a successful current synchronization; failed synchronization + preserves the last successful metadata. - Log transport, traffic collection, and metric history remain bounded and independent of page visibility. Rendering may be lazy; collection/draining ownership is not. - Logging accepts concurrent producers through one bounded ordered model; runtime-only clearing and retention cannot diff --git a/Furious/Service/SubscriptionManager.py b/Furious/Service/SubscriptionManager.py index 4565ecb..3c64837 100644 --- a/Furious/Service/SubscriptionManager.py +++ b/Furious/Service/SubscriptionManager.py @@ -85,6 +85,57 @@ SUBSCRIPTION_PROXY_OPTIONS = ( 'No proxy', ) +SUBSCRIPTION_USERINFO_HEADER = b'Subscription-Userinfo' +SUBSCRIPTION_USERINFO_KEYS = ('upload', 'download', 'total', 'expire') +MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH = 4096 +MAXIMUM_SUBSCRIPTION_USERINFO_VALUE = (1 << 63) - 1 + + +def _parseSubscriptionUserInfo(value) -> dict[str, int] | None: + """Parse bounded non-negative values from a subscription response header.""" + if value is None: + return None + + if isinstance(value, QtCore.QByteArray): + value = bytes(value) + elif isinstance(value, (bytearray, memoryview)): + value = bytes(value) + + if isinstance(value, bytes): + try: + text = value[:MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH].decode('ascii') + except UnicodeDecodeError: + return None + elif isinstance(value, str): + text = value[:MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH] + else: + return None + + if not text.strip(): + return None + + result = {key: 0 for key in SUBSCRIPTION_USERINFO_KEYS} + + for part in text.split(';'): + key, separator, rawValue = part.partition('=') + key = key.strip().casefold() + rawValue = rawValue.strip() + + if ( + not separator + or key not in result + or not rawValue.isascii() + or not rawValue.isdecimal() + ): + continue + + parsed = int(rawValue) + + if parsed <= MAXIMUM_SUBSCRIPTION_USERINFO_VALUE: + result[key] = parsed + + return result + def resolveSubscriptionProxy(option: str): """Resolve one persisted subscription proxy policy.""" @@ -838,6 +889,14 @@ class SubscriptionManager(HttpGetManager): group.lastSyncError = '' group.profileCount = len(result.profileIds) + if 'subscriptionInfo' in param: + subscriptionInfo = param.get('subscriptionInfo') or {} + + group.subscriptionUpload = subscriptionInfo.get('upload', 0) + group.subscriptionDownload = subscriptionInfo.get('download', 0) + group.subscriptionTotal = subscriptionInfo.get('total', 0) + group.subscriptionExpire = subscriptionInfo.get('expire', 0) + Storage.upsertSubscriptionGroup(group) except Exception: # Any non-exit exceptions @@ -955,7 +1014,14 @@ class SubscriptionManager(HttpGetManager): if 'batchId' in kwargs: data = bytes(networkReply.readAll().data()) decoderId = kwargs.get('decoderId') or kwargs.get('lastDecoderId') - context = {**kwargs, 'decoderId': decoderId} + subscriptionInfo = _parseSubscriptionUserInfo( + networkReply.rawHeader(SUBSCRIPTION_USERINFO_HEADER) + ) + context = { + **kwargs, + 'decoderId': decoderId, + 'subscriptionInfo': subscriptionInfo, + } if self.importer.registry.subscriptionDecoderWorkerSafe(decoderId): self._startImportPreparation(data, context) @@ -971,6 +1037,9 @@ class SubscriptionManager(HttpGetManager): failureArgs = kwargs.get('failureArgs', list()) data = bytes(networkReply.readAll().data()) + subscriptionInfo = _parseSubscriptionUserInfo( + networkReply.rawHeader(SUBSCRIPTION_USERINFO_HEADER) + ) source = SubscriptionSource( kwargs.get('unique', ''), @@ -1013,7 +1082,12 @@ class SubscriptionManager(HttpGetManager): ) successArgs.append( - {**kwargs, 'profiles': result.profiles, 'decoderId': result.decoderId} + { + **kwargs, + 'profiles': result.profiles, + 'decoderId': result.decoderId, + 'subscriptionInfo': subscriptionInfo, + } ) def failureCallback(self, networkReply, **kwargs): diff --git a/Furious/Widget/SubscriptionTableView.py b/Furious/Widget/SubscriptionTableView.py index cbd0a09..1a518e6 100644 --- a/Furious/Widget/SubscriptionTableView.py +++ b/Furious/Widget/SubscriptionTableView.py @@ -33,6 +33,7 @@ from Furious.Repository import Storage, SubscriptionGroup from Furious.Service import ( SUBSCRIPTION_AUTO_UPDATE_OPTIONS, SUBSCRIPTION_PROXY_OPTIONS, + formatTrafficUsage, resolveSubscriptionProxy, ) from Furious.Qt import ( @@ -51,8 +52,9 @@ from PySide6.QtWidgets import QAbstractItemView, QHeaderView, QTableView from typing import Union, Callable -import logging +import datetime import functools +import logging logger = logging.getLogger(__name__) @@ -76,9 +78,10 @@ class SubscriptionTableHorizontalHeader(AppQHeaderView): 'enabled', 'lastSyncStatus', 'lastUpdated', + 'subscriptionInfo', 'profiles', ) - DefaultSectionSizes = (260, 520, 120, 150, 220, 140) + DefaultSectionSizes = (260, 520, 120, 150, 220, 220, 140) LegacyColumnKeys = ('remark', 'webURL', 'autoupdate', 'proxy') # Format discriminator for the semantic JSON stored under @@ -296,6 +299,41 @@ def subscriptionSyncStatusText(item: dict) -> str: }.get(str(item.get('lastSyncStatus', '')), _('Never')) +def _nonnegativeInteger(value) -> int: + """Return a safe non-negative integer from persisted presentation input.""" + try: + return max(int(value), 0) + except (TypeError, ValueError, OverflowError): + return 0 + + +def subscriptionInfoText(item: dict) -> str: + """Return compact provider-reported traffic usage and expiry metadata.""" + parts = [] + total = _nonnegativeInteger(item.get('subscriptionTotal', 0)) + + if total: + used = _nonnegativeInteger( + item.get('subscriptionUpload', 0) + ) + _nonnegativeInteger(item.get('subscriptionDownload', 0)) + + parts.append(f'{formatTrafficUsage(used)} / {formatTrafficUsage(total)}') + + expiresAt = _nonnegativeInteger(item.get('subscriptionExpire', 0)) + + if expiresAt: + try: + expires = datetime.datetime.fromtimestamp( + expiresAt, tz=datetime.timezone.utc + ).date() + except (OverflowError, OSError, ValueError): + pass + else: + parts.append(expires.isoformat()) + + return ' · '.join(parts) + + class UserSubsTableModel(QtCore.QAbstractTableModel): """Expose user subs table data through a Qt item model.""" @@ -338,6 +376,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel): 'enabled', 'lastSyncStatus', 'lastUpdated', + 'subscriptionInfo', 'profiles', ]: flags |= QtCore.Qt.ItemFlag.ItemIsEditable @@ -409,6 +448,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel): 'enabled', 'lastSyncStatus', 'lastUpdated', + 'subscriptionInfo', 'profiles', ]: return False @@ -501,6 +541,7 @@ _TRANSLATABLE_HEADERS = [ _('Updated'), _('Update Failed'), _('Last Updated'), + _('Usage / Expiry'), _('Profiles'), ] @@ -529,6 +570,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView): 'Last Updated', lambda item: item.get('lastUpdated', ''), ), + SubscriptionTableColumn('Usage / Expiry', subscriptionInfoText), SubscriptionTableColumn('Profiles'), ] @@ -539,6 +581,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView): 'enabled', 'lastSyncStatus', 'lastUpdated', + 'subscriptionInfo', 'profiles', ] diff --git a/tests/test_repository_contracts.py b/tests/test_repository_contracts.py index a7c2ae7..a95aa34 100644 --- a/tests/test_repository_contracts.py +++ b/tests/test_repository_contracts.py @@ -200,6 +200,25 @@ class RepositoryContractTest(unittest.TestCase): self.assertEqual(tuple(repository.data()), ('A', 'B', 'C', 'D', 'E')) self.assertFalse(repository.moveGroups(('A',), 'up')) + def testSubscriptionProviderMetadataRoundTripsWithBoundedIntegers(self): + """Preserve current quota metadata while normalizing persisted input.""" + group = SubscriptionGroup.fromMapping( + 'group-a', + { + 'subscriptionUpload': '1024', + 'subscriptionDownload': 2048, + 'subscriptionTotal': -1, + 'subscriptionExpire': 999999999999999999999999, + 'futureField': 'preserved', + }, + ) + + self.assertEqual(group.subscriptionUpload, 1024) + self.assertEqual(group.subscriptionDownload, 2048) + self.assertEqual(group.subscriptionTotal, 0) + self.assertEqual(group.subscriptionExpire, (1 << 63) - 1) + self.assertEqual(group.toMapping()['futureField'], 'preserved') + def testMovingBetweenSubscriptionGroupsDetachesSyncOwnership(self): """Keep no-op ownership but make cross-group moves locally managed.""" with isolatedSettings(): diff --git a/tests/test_subscription_manager.py b/tests/test_subscription_manager.py index ed9142f..ccd2c60 100644 --- a/tests/test_subscription_manager.py +++ b/tests/test_subscription_manager.py @@ -29,6 +29,7 @@ from Furious.Qt import gettext from Furious.Service.SubscriptionManager import ( SubscriptionManager, _SubscriptionBatchState, + _parseSubscriptionUserInfo, ) from Furious.Window.SubscriptionPage import SubscriptionPage from Furious.Widget.SubscriptionTableView import SubscriptionTableView @@ -56,9 +57,12 @@ class _Payload: class _Reply: """Provide deterministic response data and failure diagnostics.""" - def __init__(self, value=b'', error='request failed'): + def __init__(self, value=b'', error='request failed', headers=None): self._value = value self._error = error + self._headers = { + bytes(name).lower(): value for name, value in (headers or {}).items() + } def readAll(self): return _Payload(self._value) @@ -66,6 +70,9 @@ class _Reply: def errorString(self): return self._error + def rawHeader(self, name): + return self._headers.get(bytes(name).lower(), b'') + class _AbortableReply: """Record cancellation of one exact service-owned network reply.""" @@ -127,7 +134,15 @@ class SubscriptionManagerTest(TestCase): return_value=None, ): manager.successCallback( - _Reply(b'payload'), + _Reply( + b'payload', + headers={ + b'Subscription-Userinfo': ( + b'upload=1024; download=2048; total=8192; ' + b'expire=1893456000' + ) + }, + ), unique='group-a', remark='Group A', webURL='https://invalid.test/subscription', @@ -138,6 +153,15 @@ class SubscriptionManagerTest(TestCase): self.assertEqual(len(successful), 1) self.assertEqual(successful[0]['profiles'], (profile,)) self.assertEqual(successful[0]['decoderId'], 'decoder') + self.assertEqual( + successful[0]['subscriptionInfo'], + { + 'upload': 1024, + 'download': 2048, + 'total': 8192, + 'expire': 1893456000, + }, + ) self.assertEqual(failed, []) manager.importer = SimpleNamespace(importPayload=mock.Mock(return_value=None)) @@ -156,6 +180,115 @@ class SubscriptionManagerTest(TestCase): self.assertEqual(failed[-1]['error'], 'UnsupportedSubscriptionFormat') manager.deleteLater() + def testSubscriptionUserInfoParserRejectsInvalidAndUnboundedValues(self): + """Treat provider quota headers as bounded advisory network input.""" + self.assertIsNone(_parseSubscriptionUserInfo(b'')) + self.assertIsNone(_parseSubscriptionUserInfo(b'upload=\xff')) + self.assertEqual( + _parseSubscriptionUserInfo(QtCore.QByteArray(b'Upload=1')), + {'upload': 1, 'download': 0, 'total': 0, 'expire': 0}, + ) + self.assertEqual( + _parseSubscriptionUserInfo( + b'upload=1024; download=2048; total=8192; expire=1893456000; ' + b'ignored=value; upload=-1; total=999999999999999999999999' + ), + { + 'upload': 1024, + 'download': 2048, + 'total': 8192, + 'expire': 1893456000, + }, + ) + + def testBatchResponseCarriesSubscriptionInfoIntoWorkerContext(self): + """Keep response metadata attached to the exact asynchronous request.""" + manager = self._manager() + manager._isCurrentRequest = mock.Mock(return_value=True) + manager.importer = SimpleNamespace( + registry=SimpleNamespace( + subscriptionDecoderWorkerSafe=mock.Mock(return_value=True) + ) + ) + manager._startImportPreparation = mock.Mock() + + manager.successCallback( + _Reply( + b'payload', + headers={ + b'Subscription-Userinfo': ( + b'upload=1024; download=2048; total=8192; ' b'expire=1893456000' + ) + }, + ), + unique='group-a', + batchId=7, + lastDecoderId='decoder', + ) + + payload, context = manager._startImportPreparation.call_args.args + self.assertEqual(payload, b'payload') + self.assertEqual( + context['subscriptionInfo'], + { + 'upload': 1024, + 'download': 2048, + 'total': 8192, + 'expire': 1893456000, + }, + ) + manager.deleteLater() + + def testSuccessfulCommitReplacesSubscriptionInfoButFailurePreservesIt(self): + """Tie advisory metadata to the same successful group commit boundary.""" + group = SubscriptionGroup( + id='group-a', + subscriptionUpload=1, + subscriptionDownload=2, + subscriptionTotal=3, + subscriptionExpire=4, + ) + result = SimpleNamespace(profileIds=('profile-a', 'profile-b')) + + with ( + mock.patch.object(Storage, 'SubscriptionGroup', return_value=group), + mock.patch.object(Storage, 'upsertSubscriptionGroup') as upsert, + ): + SubscriptionManager._recordGroupSuccess( + { + 'unique': 'group-a', + 'decoderId': 'decoder', + 'subscriptionInfo': { + 'upload': 1024, + 'download': 2048, + 'total': 8192, + 'expire': 1893456000, + }, + }, + result, + ) + SubscriptionManager._recordGroupFailure( + {'unique': 'group-a', 'error': 'offline'} + ) + metadataAfterFailure = ( + group.subscriptionUpload, + group.subscriptionDownload, + group.subscriptionTotal, + group.subscriptionExpire, + ) + SubscriptionManager._recordGroupSuccess( + {'unique': 'group-a', 'subscriptionInfo': None}, + result, + ) + + self.assertEqual(metadataAfterFailure, (1024, 2048, 8192, 1893456000)) + self.assertEqual(group.subscriptionUpload, 0) + self.assertEqual(group.subscriptionDownload, 0) + self.assertEqual(group.subscriptionTotal, 0) + self.assertEqual(group.subscriptionExpire, 0) + self.assertEqual(group.lastSyncStatus, 'success') + self.assertEqual(upsert.call_count, 3) + def testRequestFailureIsDataForPresentationNotAWidgetSideEffect(self): manager = self._manager() failed = [] @@ -701,6 +834,41 @@ class SubscriptionManagerTest(TestCase): self.assertEqual(gettext('Updating...', 'RU'), 'Обновление...') self.assertEqual(gettext('Updated', 'ZH'), '已更新') self.assertEqual(gettext('Update Failed', 'ZH'), '更新失败') + self.assertEqual(gettext('Usage / Expiry', 'RU'), 'Трафик / Срок') + self.assertEqual(gettext('Usage / Expiry', 'ZH'), '用量 / 到期') + + def testSubscriptionTableShowsOptionalUsageAndExpiryMetadata(self): + """Keep provider metadata compact and blank when it is unavailable.""" + subscriptions = { + 'group-a': self._subscription( + subscriptionUpload=1024, + subscriptionDownload=2048, + subscriptionTotal=8192, + subscriptionExpire=1893456000, + ), + 'group-b': self._subscription( + remark='Group B', + webURL='https://invalid.test/b', + ), + } + + with mock.patch.object(Storage, 'UserSubs', return_value=subscriptions): + table = SubscriptionTableView() + model = table.sourceModel + column = table.ItemKey.index('subscriptionInfo') + + self.assertEqual( + model.data(model.index(0, column), QtCore.Qt.ItemDataRole.DisplayRole), + '3 KiB / 8 KiB · 2030-01-01', + ) + self.assertEqual( + model.data(model.index(1, column), QtCore.Qt.ItemDataRole.DisplayRole), + '', + ) + self.assertFalse( + model.flags(model.index(0, column)) & QtCore.Qt.ItemFlag.ItemIsEditable + ) + table.deleteLater() def testSubscriptionStateNotificationRepaintsOnlyAffectedMetadataRow(self): """Resolve stable IDs at delivery and avoid a whole-table refresh."""