Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docker-hub-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ 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
run: docker push mobilesecurity/mdast_cli:latest

- name: Docker Hub push tagged image

run: docker push mobilesecurity/mdast_cli:2026.8.1
run: docker push mobilesecurity/mdast_cli:2026.8.2



Expand Down
2 changes: 1 addition & 1 deletion mdast_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '2026.8.1'
__version__ = '2026.8.2'
107 changes: 80 additions & 27 deletions mdast_cli/distribution_systems/rustore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
setup(
name="mdast_cli",

version='2026.8.1',
version='2026.8.2',

python_requires='>=3.12',

Expand Down
93 changes: 93 additions & 0 deletions tests/test_rustore.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import zipfile
from unittest import mock

import pytest
from urllib3.exceptions import InsecureRequestWarning

from mdast_cli.distribution_systems import rustore
Expand Down Expand Up @@ -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
Loading