From 5f4787d04cf3037704bfe7257660b7b28de62b8a Mon Sep 17 00:00:00 2001 From: SergeyOstrouhov Date: Wed, 19 Aug 2026 15:51:39 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(rustore):=20ruStoreVerCode=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B2=20=D0=B4=D0=BE=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Запросы к backapi RuStore падали с пустым HTTP 419, из-за чего --distribution_system rustore не работал совсем. Значение 2000000000 было выбрано в расчёте на то, что RuStore проверяет только нижнюю границу (247) и принимает любое большее число, поэтому "never needs bumping". Это предположение больше не верно: сервер проверяет диапазон, и превышение верхней границы даёт пустой 419. Замеры по applicationData/overallInfo: нет заголовка / не число -> 400 < 247 -> 417 247 … 1_100_000 -> 200 стабильно (12/12) 1_150_000 -> 200 лишь 4/12 2_000_000_000 -> 200 лишь 5/20 Инстансы бэкенда сконфигурированы неоднородно: одно и то же значение то проходит, то нет. Поэтому баг выглядел плавающим — до сих пор запросы иногда попадали на инстансы без верхней границы. Судя по заголовкам ответа (Server: kittenx, набор Spring Security), 419 отдаёт само приложение, а не edge/WAF; Retry-After отсутствует. Изменения: - значение по умолчанию 1000000: с запасом над минимумом 247 и уверенно внутри принимаемого диапазона; - request_with_ver_code() оборачивает оба вызова: на 417/419 значение повторяется до 3 раз (лечит неоднородность фарма), затем берётся следующее из VER_CODE_FALLBACKS. Ранее download-link не имел защиты вообще, хотя тоже мог попасть на строгий инстанс; - в сообщения об ошибках 417/419 добавлена подсказка про границу и MDAST_RUSTORE_VER_CODE — прежний пустой "body:" не давал ничего для диагностики; - 4 регресс-теста, включая проверку границ значения по умолчанию. Точная верхняя граница в коде не зашита — она может двигаться, для этого есть fallback-лестница и override через MDAST_RUSTORE_VER_CODE. Co-Authored-By: Claude Opus 5 (1M context) --- mdast_cli/distribution_systems/rustore.py | 107 ++++++++++++++++------ tests/test_rustore.py | 93 +++++++++++++++++++ 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/mdast_cli/distribution_systems/rustore.py b/mdast_cli/distribution_systems/rustore.py index 73311ed..2a529a3 100644 --- a/mdast_cli/distribution_systems/rustore.py +++ b/mdast_cli/distribution_systems/rustore.py @@ -12,12 +12,58 @@ logger = logging.getLogger(__name__) # Since July 2026 RuStore backapi rejects requests without a client version header: -# a missing/non-numeric value returns an empty HTTP 400 from the "kittenx" edge, -# and a numeric value below the minimum returns HTTP 417. The server only enforces -# a lower bound (currently 247) and accepts any larger integer, so we send a value -# far above it that never needs bumping as the real RuStore client version changes. -# Override via the MDAST_RUSTORE_VER_CODE env var if the minimum is ever raised. -RUSTORE_VER_CODE = os.environ.get('MDAST_RUSTORE_VER_CODE', '2000000000') +# a missing/non-numeric value returns an empty HTTP 400 from the "kittenx" edge. +# Contrary to the earlier assumption that only a lower bound existed, the server +# enforces a *range*: a value below the minimum (currently 247) returns HTTP 417, +# and a value above the maximum (currently between 1_100_000 and 1_150_000) returns +# an empty HTTP 419. We therefore send a value with plenty of headroom over the +# minimum while staying well inside the accepted range. +# Override via the MDAST_RUSTORE_VER_CODE env var if the bounds change again. +RUSTORE_VER_CODE = os.environ.get('MDAST_RUSTORE_VER_CODE', '1000000') + +# Values tried in order when the primary one is rejected. RuStore's backend fleet is +# not uniformly configured — a value accepted by one instance can be rejected by the +# next — so each candidate is retried a few times before moving on. +VER_CODE_FALLBACKS = ('1000000', '247') +VER_CODE_REJECT_STATUSES = (417, 419) +VER_CODE_ATTEMPTS = 3 + + +def request_with_ver_code(send, description): + """ + Выполнить запрос к backapi RuStore, подбирая значение заголовка ruStoreVerCode. + + Аргумент send — функция, принимающая значение ruStoreVerCode и возвращающая ответ. + Статусы 417 и 419 означают, что значение вне допустимого диапазона; в этом случае + значение пробуется повторно (инстансы бэкенда настроены неодинаково), а затем + берётся следующее из VER_CODE_FALLBACKS. Возвращается последний полученный ответ, + чтобы вызывающий код сам сформировал сообщение об ошибке. + """ + candidates = [] + for ver_code in (RUSTORE_VER_CODE, *VER_CODE_FALLBACKS): + if ver_code and ver_code not in candidates: + candidates.append(ver_code) + + resp = None + for ver_code in candidates: + for attempt in range(VER_CODE_ATTEMPTS): + resp = send(ver_code) + if resp.status_code not in VER_CODE_REJECT_STATUSES: + return resp + logger.debug(f'Rustore - {description}: status {resp.status_code} for ' + f'ruStoreVerCode={ver_code} (attempt {attempt + 1}/{VER_CODE_ATTEMPTS})') + logger.warning(f'Rustore - {description}: ruStoreVerCode={ver_code} rejected with status ' + f'{resp.status_code}, trying next value') + return resp + + +def ver_code_hint(status_code): + """Подсказка для статусов, означающих недопустимое значение ruStoreVerCode.""" + if status_code not in VER_CODE_REJECT_STATUSES: + return '' + bound = 'below the minimum' if status_code == 417 else 'above the maximum' + return (f' RuStore rejected the client version header as {bound} accepted value; ' + f'set the MDAST_RUSTORE_VER_CODE env var to a supported ruStoreVerCode.') def get_app_info(package_name): @@ -29,16 +75,20 @@ def get_app_info(package_name): - Исправлена ошибка, при которой статус POST проверялся по предыдущему ответу GET. - Предоставляются подробные сообщения об ошибках со статусом и фрагментом ответа. """ - common_headers = { - 'User-Agent': 'mdast-cli/1.0 (+https://stingray-tech.ru)', - 'Accept': 'application/json', - 'ruStoreVerCode': RUSTORE_VER_CODE - } - - req = requests.get( - f'https://backapi.rustore.ru/applicationData/overallInfo/{package_name}', - headers=common_headers, - timeout=30 + def common_headers(ver_code): + return { + 'User-Agent': 'mdast-cli/1.0 (+https://stingray-tech.ru)', + 'Accept': 'application/json', + 'ruStoreVerCode': ver_code + } + + req = request_with_ver_code( + lambda ver_code: requests.get( + f'https://backapi.rustore.ru/applicationData/overallInfo/{package_name}', + headers=common_headers(ver_code), + timeout=30 + ), + 'overallInfo' ) if req.status_code == 200: body = req.json() @@ -49,22 +99,25 @@ def get_app_info(package_name): f" version:{body_info['versionName']}, company: {body_info['companyName']}") else: raise RuntimeError( - f"Rustore - Failed to get application info. Status: {req.status_code}, body: {req.text[:500]}" + f"Rustore - Failed to get application info. Status: {req.status_code}, " + f"body: {req.text[:500]}{ver_code_hint(req.status_code)}" ) - headers = { - 'Content-Type': 'application/json; charset=utf-8', - **common_headers - } body = { 'appId': body_info['appId'], 'firstInstall': True } - download_link_resp = requests.post( - 'https://backapi.rustore.ru/applicationData/download-link', - headers=headers, - json=body, - timeout=30 + download_link_resp = request_with_ver_code( + lambda ver_code: requests.post( + 'https://backapi.rustore.ru/applicationData/download-link', + headers={ + 'Content-Type': 'application/json; charset=utf-8', + **common_headers(ver_code) + }, + json=body, + timeout=30 + ), + 'download-link' ) if download_link_resp.status_code == 200: dl_json = download_link_resp.json() @@ -76,7 +129,7 @@ def get_app_info(package_name): else: raise RuntimeError( f"Rustore - Failed to get application download link. Status: {download_link_resp.status_code}, " - f"body: {download_link_resp.text[:500]}" + f"body: {download_link_resp.text[:500]}{ver_code_hint(download_link_resp.status_code)}" ) return { diff --git a/tests/test_rustore.py b/tests/test_rustore.py index 96056d0..bd2e527 100644 --- a/tests/test_rustore.py +++ b/tests/test_rustore.py @@ -3,6 +3,7 @@ import zipfile from unittest import mock +import pytest from urllib3.exceptions import InsecureRequestWarning from mdast_cli.distribution_systems import rustore @@ -64,3 +65,95 @@ def request(*_args, **_kwargs): ) assert not caught assert zipfile.is_zipfile(result) + + +def _overall_info_json(): + return {'body': { + 'appId': 42, + 'packageName': 'com.example.app', + 'versionName': '1.0', + 'versionCode': 100, + 'companyName': 'Example LLC', + 'minSdkVersion': 21, + 'maxSdkVersion': 0, + 'targetSdkVersion': 33, + 'fileSize': 1234, + 'iconUrl': 'https://static.rustore.ru/icon.png', + }} + + +def _ver_code_stub(statuses_by_ver_code, json_body): + """Build a requests stub whose status depends on the ruStoreVerCode header sent. + + statuses_by_ver_code maps a ver_code to either a single status or a list of + statuses returned on successive calls (to emulate RuStore's inconsistent fleet). + Records every ver_code seen in the returned `seen` list. + """ + seen = [] + + def send(*_args, **kwargs): + ver_code = kwargs['headers']['ruStoreVerCode'] + seen.append(ver_code) + status = statuses_by_ver_code.get(ver_code, 419) + if isinstance(status, list): + status = status.pop(0) if len(status) > 1 else status[0] + return mock.Mock(status_code=status, text='', + json=mock.Mock(return_value=json_body)) + + return mock.Mock(side_effect=send), seen + + +def test_get_app_info_falls_back_when_ver_code_rejected(monkeypatch): + """A ver_code outside RuStore's accepted range must not fail the run outright.""" + monkeypatch.setattr(rustore, 'RUSTORE_VER_CODE', '2000000000') + monkeypatch.setattr(rustore, 'VER_CODE_FALLBACKS', ('247',)) + + get, get_seen = _ver_code_stub({'2000000000': 419, '247': 200}, _overall_info_json()) + post, post_seen = _ver_code_stub({'2000000000': 419, '247': 200}, + {'body': {'apkUrl': 'https://static.rustore.ru/app.apk'}}) + monkeypatch.setattr(rustore.requests, 'get', get) + monkeypatch.setattr(rustore.requests, 'post', post) + + info = rustore.get_app_info('com.example.app') + + assert info['download_url'] == 'https://static.rustore.ru/app.apk' + assert info['package_name'] == 'com.example.app' + # Rejected value is retried before moving on, then the fallback succeeds. + assert get_seen == ['2000000000'] * rustore.VER_CODE_ATTEMPTS + ['247'] + assert post_seen == ['2000000000'] * rustore.VER_CODE_ATTEMPTS + ['247'] + + +def test_get_app_info_retries_same_ver_code_on_inconsistent_rejection(monkeypatch): + """RuStore instances disagree on the accepted range, so a retry alone can succeed.""" + monkeypatch.setattr(rustore, 'RUSTORE_VER_CODE', '1000000') + monkeypatch.setattr(rustore, 'VER_CODE_FALLBACKS', ('247',)) + + get, get_seen = _ver_code_stub({'1000000': [419, 200]}, _overall_info_json()) + post, _ = _ver_code_stub({'1000000': 200}, + {'body': {'apkUrl': 'https://static.rustore.ru/app.apk'}}) + monkeypatch.setattr(rustore.requests, 'get', get) + monkeypatch.setattr(rustore.requests, 'post', post) + + info = rustore.get_app_info('com.example.app') + + assert info['version_name'] == '1.0' + assert get_seen == ['1000000', '1000000'] + + +def test_get_app_info_error_mentions_ver_code_when_all_rejected(monkeypatch): + monkeypatch.setattr(rustore, 'RUSTORE_VER_CODE', '2000000000') + monkeypatch.setattr(rustore, 'VER_CODE_FALLBACKS', ('247',)) + + get, _ = _ver_code_stub({}, _overall_info_json()) + monkeypatch.setattr(rustore.requests, 'get', get) + + with pytest.raises(RuntimeError) as excinfo: + rustore.get_app_info('com.example.app') + + assert 'MDAST_RUSTORE_VER_CODE' in str(excinfo.value) + assert '419' in str(excinfo.value) + + +def test_default_ver_code_is_inside_accepted_range(): + """Guards against reintroducing a value above RuStore's upper bound (HTTP 419).""" + assert 247 <= int(rustore.RUSTORE_VER_CODE) <= 1_100_000 From 1385087b58c824e407e676aaed1e45b7a0fb07ab Mon Sep 17 00:00:00 2001 From: SergeyOstrouhov Date: Wed, 19 Aug 2026 15:51:39 +0300 Subject: [PATCH 2/2] release: 2026.8.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бамп версии в __init__.py, setup.py и docker-тегах после фикса диапазона ruStoreVerCode. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docker-hub-publish.yml | 4 ++-- mdast_cli/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-hub-publish.yml b/.github/workflows/docker-hub-publish.yml index 618368e..dc98ba8 100644 --- a/.github/workflows/docker-hub-publish.yml +++ b/.github/workflows/docker-hub-publish.yml @@ -34,7 +34,7 @@ jobs: - name: Build the Docker image - run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.8.1 -t mobilesecurity/mdast_cli:latest + run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.8.2 -t mobilesecurity/mdast_cli:latest - name: Docker Hub push latest image @@ -42,7 +42,7 @@ jobs: - name: Docker Hub push tagged image - run: docker push mobilesecurity/mdast_cli:2026.8.1 + run: docker push mobilesecurity/mdast_cli:2026.8.2 diff --git a/mdast_cli/__init__.py b/mdast_cli/__init__.py index 9e265d0..22da1f7 100644 --- a/mdast_cli/__init__.py +++ b/mdast_cli/__init__.py @@ -1 +1 @@ -__version__ = '2026.8.1' +__version__ = '2026.8.2' diff --git a/setup.py b/setup.py index afbb5b6..6d9c6c1 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="mdast_cli", - version='2026.8.1', + version='2026.8.2', python_requires='>=3.12',