rustore-apk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mffff4
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: rustore-apk
3
+ Version: 0.1.0
4
+ Summary: Download APKs published in RuStore
5
+ Author: Mffff4
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Mffff4/rustore-apk
8
+ Project-URL: Repository, https://github.com/Mffff4/rustore-apk
9
+ Project-URL: Issues, https://github.com/Mffff4/rustore-apk/issues
10
+ Keywords: rustore,apk,downloader,android
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: System :: Software Distribution
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # rustore-apk
25
+
26
+ [![PyPI version](https://img.shields.io/pypi/v/rustore-apk.svg)](https://pypi.org/project/rustore-apk/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/rustore-apk.svg)](https://pypi.org/project/rustore-apk/)
28
+
29
+ A dependency-free CLI for downloading APK artifacts published in [RuStore](https://www.rustore.ru/). It uses Python's standard-library `urllib` only.
30
+
31
+ ## Install / run
32
+
33
+ With [uv](https://docs.astral.sh/uv/):
34
+
35
+ ```bash
36
+ uvx rustore-apk DangerousSlope.apk
37
+ ```
38
+
39
+ A RuStore catalog URL works too:
40
+
41
+ ```bash
42
+ uvx rustore-apk \
43
+ 'https://www.rustore.ru/catalog/app/DangerousSlope.apk' \
44
+ --out ./downloads
45
+ ```
46
+
47
+ `-o` is short for `--out`. A multi-file split APK bundle is downloaded as separate files into that directory.
48
+
49
+ Show metadata without requesting a download link:
50
+
51
+ ```bash
52
+ uvx rustore-apk DangerousSlope.apk --info
53
+ ```
54
+
55
+ ## Security behavior
56
+
57
+ - Retrieves a temporary download URL through RuStore's public Android-client API flow.
58
+ - Accepts only HTTPS download links returned from RuStore/VK CDN domains.
59
+ - Checks that each downloaded artifact is a ZIP/APK container.
60
+ - Prints SHA-256 for every saved artifact.
61
+ - Does not send account credentials and does not require a RuStore account.
62
+
63
+ TLS verification is on by default. If a minimal container has no valid CA roots, pass a PEM CA bundle:
64
+
65
+ ```bash
66
+ rustore-apk DangerousSlope.apk --ca-file /path/to/ca-bundle.pem
67
+ ```
68
+
69
+ `--insecure` disables TLS certificate validation. It is intended solely to diagnose a broken local CA store and is **not recommended** for routine downloads.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ uv sync --dev
75
+ uv run ruff check .
76
+ uv run pytest
77
+ uv build
78
+ ```
79
+
80
+ ## Disclaimer
81
+
82
+ This project is an unofficial client and is not affiliated with RuStore or VK. RuStore API behavior and availability may change. Download and use apps only where permitted by their publisher and RuStore's terms.
83
+
84
+ ## License
85
+
86
+ MIT.
@@ -0,0 +1,63 @@
1
+ # rustore-apk
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/rustore-apk.svg)](https://pypi.org/project/rustore-apk/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/rustore-apk.svg)](https://pypi.org/project/rustore-apk/)
5
+
6
+ A dependency-free CLI for downloading APK artifacts published in [RuStore](https://www.rustore.ru/). It uses Python's standard-library `urllib` only.
7
+
8
+ ## Install / run
9
+
10
+ With [uv](https://docs.astral.sh/uv/):
11
+
12
+ ```bash
13
+ uvx rustore-apk DangerousSlope.apk
14
+ ```
15
+
16
+ A RuStore catalog URL works too:
17
+
18
+ ```bash
19
+ uvx rustore-apk \
20
+ 'https://www.rustore.ru/catalog/app/DangerousSlope.apk' \
21
+ --out ./downloads
22
+ ```
23
+
24
+ `-o` is short for `--out`. A multi-file split APK bundle is downloaded as separate files into that directory.
25
+
26
+ Show metadata without requesting a download link:
27
+
28
+ ```bash
29
+ uvx rustore-apk DangerousSlope.apk --info
30
+ ```
31
+
32
+ ## Security behavior
33
+
34
+ - Retrieves a temporary download URL through RuStore's public Android-client API flow.
35
+ - Accepts only HTTPS download links returned from RuStore/VK CDN domains.
36
+ - Checks that each downloaded artifact is a ZIP/APK container.
37
+ - Prints SHA-256 for every saved artifact.
38
+ - Does not send account credentials and does not require a RuStore account.
39
+
40
+ TLS verification is on by default. If a minimal container has no valid CA roots, pass a PEM CA bundle:
41
+
42
+ ```bash
43
+ rustore-apk DangerousSlope.apk --ca-file /path/to/ca-bundle.pem
44
+ ```
45
+
46
+ `--insecure` disables TLS certificate validation. It is intended solely to diagnose a broken local CA store and is **not recommended** for routine downloads.
47
+
48
+ ## Development
49
+
50
+ ```bash
51
+ uv sync --dev
52
+ uv run ruff check .
53
+ uv run pytest
54
+ uv build
55
+ ```
56
+
57
+ ## Disclaimer
58
+
59
+ This project is an unofficial client and is not affiliated with RuStore or VK. RuStore API behavior and availability may change. Download and use apps only where permitted by their publisher and RuStore's terms.
60
+
61
+ ## License
62
+
63
+ MIT.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rustore-apk"
7
+ version = "0.1.0"
8
+ description = "Download APKs published in RuStore"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "Mffff4"}]
13
+ keywords = ["rustore", "apk", "downloader", "android"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Internet :: WWW/HTTP",
22
+ "Topic :: System :: Software Distribution",
23
+ ]
24
+ dependencies = []
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "build>=1.2",
29
+ "pytest>=8.0",
30
+ "ruff>=0.9",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/Mffff4/rustore-apk"
35
+ Repository = "https://github.com/Mffff4/rustore-apk"
36
+ Issues = "https://github.com/Mffff4/rustore-apk/issues"
37
+
38
+ [project.scripts]
39
+ rustore-apk = "rustore_apk.cli:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ include = ["rustore_apk*"]
43
+
44
+ [tool.setuptools.package-data]
45
+ rustore_apk = ["py.typed"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py310"
50
+ exclude = ["build", "dist", "rustore_urllib"]
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ pythonpath = ["."]
@@ -0,0 +1,3 @@
1
+ """Download APKs published in RuStore."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ssl
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .client import Client, RuStoreError, package_from_argument, secure_context
9
+
10
+
11
+ def _parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="rustore-apk",
14
+ description="Download APKs published in RuStore.",
15
+ epilog="Examples:\n"
16
+ " rustore-apk DangerousSlope.apk\n"
17
+ " rustore-apk https://www.rustore.ru/catalog/app/DangerousSlope.apk -o downloads\n"
18
+ " rustore-apk DangerousSlope.apk --info",
19
+ formatter_class=argparse.RawDescriptionHelpFormatter,
20
+ )
21
+ parser.add_argument("app", help="RuStore catalog URL or Android package name")
22
+ parser.add_argument(
23
+ "-o", "--out", default=".", help="Output directory (default: current directory)"
24
+ )
25
+ parser.add_argument(
26
+ "--info", action="store_true", help="Print app metadata only; do not request download links"
27
+ )
28
+ parser.add_argument(
29
+ "--timeout", type=float, default=45.0, help="Network timeout in seconds (default: 45)"
30
+ )
31
+ parser.add_argument(
32
+ "--ca-file",
33
+ metavar="PATH",
34
+ help="PEM CA bundle for minimal containers without system trust roots",
35
+ )
36
+ parser.add_argument(
37
+ "--insecure",
38
+ action="store_true",
39
+ help="Disable TLS certificate verification (diagnostic use only; not recommended)",
40
+ )
41
+ return parser
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ args = _parser().parse_args(argv)
46
+ try:
47
+ context = (
48
+ ssl._create_unverified_context() if args.insecure else secure_context(args.ca_file)
49
+ )
50
+ client = Client(args.timeout, context)
51
+ app = client.app(package_from_argument(args.app))
52
+ version = app.version_name or "?"
53
+ print(f"{app.app_name} ({app.package_name}) v{version}")
54
+ if args.info:
55
+ return 0
56
+
57
+ output = Path(args.out)
58
+ output.mkdir(parents=True, exist_ok=True)
59
+ urls = client.download_urls(app.app_id)
60
+ for index, url in enumerate(urls, start=1):
61
+ target = output / client.file_name_from_url(url, f"{app.package_name}-{index}.apk")
62
+ count, digest = client.download(url, target)
63
+ print(f"saved {target} ({count} bytes)\nsha256 {digest}")
64
+ return 0
65
+ except RuStoreError as exc:
66
+ print(f"error: {exc}", file=sys.stderr)
67
+ return 2
68
+
69
+
70
+ if __name__ == "__main__":
71
+ raise SystemExit(main())
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import http.cookiejar
7
+ import json
8
+ import re
9
+ import secrets
10
+ import ssl
11
+ import zipfile
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.error import HTTPError, URLError
16
+ from urllib.parse import unquote, urlparse
17
+ from urllib.request import HTTPCookieProcessor, HTTPSHandler, Request, build_opener
18
+
19
+ HMAC_KEY = base64.b64decode("K+eeiCbnVFnZ71KEVal0g5siHaX6v6drh8upeLgEPoU=")
20
+ APK_CERT_SHA256 = base64.b64decode("Zh8ggo73gN4LebxZ8mowhkMWNV8w5Pkc+hSiB5GDmRQ=")
21
+ BASE_URL = "https://backapi.rustore.ru"
22
+ NONCE_URL = "https://api.rustore.ru/v1/secure/nonce"
23
+ CATALOG_RE = re.compile(r"(?:https?://)?(?:www\.)?rustore\.ru/catalog/app/([^/?#]+)")
24
+ PACKAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$")
25
+ DEVICE_MANUFACTURER = "Google"
26
+ DEVICE_MODEL = "Pixel 8 Pro"
27
+ DEVICE_HARDWARE = "husky"
28
+ FIRMWARE_VER = "16"
29
+ ANDROID_SDK_VER = "36"
30
+ FIRMWARE_LANG = "ru"
31
+ RUSTORE_VERSION = "1105002"
32
+ USER_AGENT = (
33
+ f"RuStore/1.105.0.2 (Android {FIRMWARE_VER}; SDK {ANDROID_SDK_VER}; "
34
+ f"arm64-v8a; {DEVICE_MANUFACTURER} {DEVICE_MODEL}; {FIRMWARE_LANG})"
35
+ )
36
+
37
+
38
+ class RuStoreError(RuntimeError):
39
+ """Raised when RuStore does not return a usable app or download artifact."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class App:
44
+ app_id: int
45
+ package_name: str
46
+ app_name: str
47
+ version_name: str | None
48
+
49
+
50
+ def package_from_argument(value: str) -> str:
51
+ """Extract a package name from a catalog URL or accept a package name directly."""
52
+ match = CATALOG_RE.search(value.strip())
53
+ package = unquote(match.group(1)) if match else value.strip()
54
+ if not PACKAGE_RE.fullmatch(package):
55
+ raise RuStoreError("Pass a RuStore catalog URL or a valid Android package name")
56
+ return package
57
+
58
+
59
+ def secure_context(cafile: str | None) -> ssl.SSLContext:
60
+ try:
61
+ return ssl.create_default_context(cafile=cafile)
62
+ except FileNotFoundError as exc:
63
+ raise RuStoreError(f"CA file not found: {cafile}") from exc
64
+
65
+
66
+ def _java_hash(value: str) -> int:
67
+ result = 0
68
+ for char in value:
69
+ result = (31 * result + ord(char)) & 0xFFFFFFFF
70
+ return result - 0x100000000 if result >= 0x80000000 else result
71
+
72
+
73
+ def _device_id() -> str:
74
+ manufacturer, model, hardware = map(
75
+ _java_hash, (DEVICE_MANUFACTURER, DEVICE_MODEL, DEVICE_HARDWARE)
76
+ )
77
+ combined = hardware + ((hardware + ((model + manufacturer * 31) * 31)) * 31)
78
+ combined = (combined + 0x80000000) & 0xFFFFFFFF
79
+ if combined >= 0x80000000:
80
+ combined -= 0x100000000
81
+ return f"{secrets.token_hex(8)}-{combined}"
82
+
83
+
84
+ class Client:
85
+ def __init__(self, timeout: float, ssl_context: ssl.SSLContext) -> None:
86
+ self.timeout = timeout
87
+ self.device_id = _device_id()
88
+ self.signature: str | None = None
89
+ self.opener = build_opener(
90
+ HTTPCookieProcessor(http.cookiejar.CookieJar()), HTTPSHandler(context=ssl_context)
91
+ )
92
+
93
+ def _headers(self, signed: bool = True) -> dict[str, str]:
94
+ headers = {
95
+ "deviceId": self.device_id,
96
+ "firmwareVer": FIRMWARE_VER,
97
+ "androidSdkVer": ANDROID_SDK_VER,
98
+ "deviceManufacturerName": DEVICE_MANUFACTURER,
99
+ "deviceModelName": DEVICE_MODEL,
100
+ "deviceModel": f"{DEVICE_MANUFACTURER} {DEVICE_MODEL}",
101
+ "firmwareLang": FIRMWARE_LANG,
102
+ "ruStoreVerCode": RUSTORE_VERSION,
103
+ "deviceType": "mobile",
104
+ "User-Agent": USER_AGENT,
105
+ }
106
+ if signed and self.signature:
107
+ headers["X-Client-Signature"] = self.signature
108
+ return headers
109
+
110
+ def _open(self, request: Request):
111
+ try:
112
+ return self.opener.open(request, timeout=self.timeout)
113
+ except HTTPError:
114
+ raise
115
+ except URLError as exc:
116
+ raise RuStoreError(f"Network error: {exc.reason}") from exc
117
+
118
+ def _sign(self, force: bool = False) -> None:
119
+ if self.signature and not force:
120
+ return
121
+ try:
122
+ request = Request(NONCE_URL, data=b"", method="POST", headers=self._headers(False))
123
+ with self._open(request) as response:
124
+ nonce = json.load(response).get("nonce")
125
+ except HTTPError as exc:
126
+ raise RuStoreError(f"Nonce request failed: HTTP {exc.code}") from exc
127
+ if not nonce:
128
+ raise RuStoreError("RuStore nonce response did not contain nonce")
129
+ digest = hmac.new(
130
+ HMAC_KEY, base64.b64decode(nonce) + APK_CERT_SHA256, hashlib.sha256
131
+ ).digest()
132
+ self.signature = base64.b64encode(digest).decode("ascii")
133
+
134
+ def _api(self, method: str, url: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
135
+ self._sign()
136
+ raw = json.dumps(body, separators=(",", ":")).encode() if body is not None else None
137
+
138
+ def request() -> Request:
139
+ headers = self._headers()
140
+ if raw is not None:
141
+ headers["Content-Type"] = "application/json; charset=utf-8"
142
+ return Request(url, data=raw, method=method, headers=headers)
143
+
144
+ try:
145
+ with self._open(request()) as response:
146
+ return json.load(response)
147
+ except HTTPError as exc:
148
+ if exc.code != 419:
149
+ raise RuStoreError(f"RuStore API request failed: HTTP {exc.code}") from exc
150
+
151
+ self._sign(force=True)
152
+ try:
153
+ with self._open(request()) as response:
154
+ return json.load(response)
155
+ except HTTPError as exc:
156
+ raise RuStoreError(f"RuStore API retry failed: HTTP {exc.code}") from exc
157
+
158
+ def app(self, package_name: str) -> App:
159
+ payload = self._api("GET", f"{BASE_URL}/applicationData/overallInfo/{package_name}")
160
+ body = payload.get("body")
161
+ if payload.get("code") != "OK" or not isinstance(body, dict):
162
+ raise RuStoreError(f"Package not found in RuStore: {package_name}")
163
+ return App(
164
+ int(body["appId"]),
165
+ str(body["packageName"]),
166
+ str(body.get("appName") or body["packageName"]),
167
+ body.get("versionName"),
168
+ )
169
+
170
+ def download_urls(self, app_id: int) -> list[str]:
171
+ payload = self._api(
172
+ "POST",
173
+ f"{BASE_URL}/v3/showcase/apps/download-link",
174
+ {"appId": app_id, "firstInstall": True},
175
+ )
176
+ items = payload.get("downloadUrls") or payload.get("body", {}).get("downloadUrls") or []
177
+ urls = [str(item.get("url", "")) for item in items if isinstance(item, dict)]
178
+ urls = [url[:-4] + ".apk" if url.endswith(".zip") else url for url in urls]
179
+ if not urls:
180
+ raise RuStoreError("RuStore returned no APK download URLs")
181
+ for url in urls:
182
+ parsed = urlparse(url)
183
+ host = parsed.hostname or ""
184
+ if parsed.scheme != "https" or not (
185
+ host.endswith(".rustore.ru") or host.endswith(".vk.ru")
186
+ ):
187
+ raise RuStoreError(f"Unexpected download host from RuStore API: {host}")
188
+ return urls
189
+
190
+ @staticmethod
191
+ def file_name_from_url(url: str, fallback: str) -> str:
192
+ name = Path(urlparse(url).path).name or fallback
193
+ return name if name.endswith(".apk") else f"{name}.apk"
194
+
195
+ def download(self, url: str, target: Path) -> tuple[int, str]:
196
+ target.parent.mkdir(parents=True, exist_ok=True)
197
+ digest, total = hashlib.sha256(), 0
198
+ try:
199
+ request = Request(url, headers={"User-Agent": USER_AGENT})
200
+ with self._open(request) as response, target.open("wb") as output:
201
+ while chunk := response.read(256 * 1024):
202
+ output.write(chunk)
203
+ digest.update(chunk)
204
+ total += len(chunk)
205
+ except HTTPError as exc:
206
+ target.unlink(missing_ok=True)
207
+ raise RuStoreError(f"APK download failed: HTTP {exc.code}") from exc
208
+ if not zipfile.is_zipfile(target):
209
+ target.unlink(missing_ok=True)
210
+ raise RuStoreError("Downloaded file is not a valid APK/ZIP container")
211
+ return total, digest.hexdigest()
File without changes
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: rustore-apk
3
+ Version: 0.1.0
4
+ Summary: Download APKs published in RuStore
5
+ Author: Mffff4
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Mffff4/rustore-apk
8
+ Project-URL: Repository, https://github.com/Mffff4/rustore-apk
9
+ Project-URL: Issues, https://github.com/Mffff4/rustore-apk/issues
10
+ Keywords: rustore,apk,downloader,android
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: System :: Software Distribution
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # rustore-apk
25
+
26
+ [![PyPI version](https://img.shields.io/pypi/v/rustore-apk.svg)](https://pypi.org/project/rustore-apk/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/rustore-apk.svg)](https://pypi.org/project/rustore-apk/)
28
+
29
+ A dependency-free CLI for downloading APK artifacts published in [RuStore](https://www.rustore.ru/). It uses Python's standard-library `urllib` only.
30
+
31
+ ## Install / run
32
+
33
+ With [uv](https://docs.astral.sh/uv/):
34
+
35
+ ```bash
36
+ uvx rustore-apk DangerousSlope.apk
37
+ ```
38
+
39
+ A RuStore catalog URL works too:
40
+
41
+ ```bash
42
+ uvx rustore-apk \
43
+ 'https://www.rustore.ru/catalog/app/DangerousSlope.apk' \
44
+ --out ./downloads
45
+ ```
46
+
47
+ `-o` is short for `--out`. A multi-file split APK bundle is downloaded as separate files into that directory.
48
+
49
+ Show metadata without requesting a download link:
50
+
51
+ ```bash
52
+ uvx rustore-apk DangerousSlope.apk --info
53
+ ```
54
+
55
+ ## Security behavior
56
+
57
+ - Retrieves a temporary download URL through RuStore's public Android-client API flow.
58
+ - Accepts only HTTPS download links returned from RuStore/VK CDN domains.
59
+ - Checks that each downloaded artifact is a ZIP/APK container.
60
+ - Prints SHA-256 for every saved artifact.
61
+ - Does not send account credentials and does not require a RuStore account.
62
+
63
+ TLS verification is on by default. If a minimal container has no valid CA roots, pass a PEM CA bundle:
64
+
65
+ ```bash
66
+ rustore-apk DangerousSlope.apk --ca-file /path/to/ca-bundle.pem
67
+ ```
68
+
69
+ `--insecure` disables TLS certificate validation. It is intended solely to diagnose a broken local CA store and is **not recommended** for routine downloads.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ uv sync --dev
75
+ uv run ruff check .
76
+ uv run pytest
77
+ uv build
78
+ ```
79
+
80
+ ## Disclaimer
81
+
82
+ This project is an unofficial client and is not affiliated with RuStore or VK. RuStore API behavior and availability may change. Download and use apps only where permitted by their publisher and RuStore's terms.
83
+
84
+ ## License
85
+
86
+ MIT.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ rustore_apk/__init__.py
5
+ rustore_apk/cli.py
6
+ rustore_apk/client.py
7
+ rustore_apk/py.typed
8
+ rustore_apk.egg-info/PKG-INFO
9
+ rustore_apk.egg-info/SOURCES.txt
10
+ rustore_apk.egg-info/dependency_links.txt
11
+ rustore_apk.egg-info/entry_points.txt
12
+ rustore_apk.egg-info/top_level.txt
13
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rustore-apk = rustore_apk.cli:main
@@ -0,0 +1 @@
1
+ rustore_apk
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ from rustore_apk.client import Client, RuStoreError, package_from_argument
2
+
3
+
4
+ def test_accepts_catalog_url() -> None:
5
+ assert (
6
+ package_from_argument("https://www.rustore.ru/catalog/app/DangerousSlope.apk")
7
+ == "DangerousSlope.apk"
8
+ )
9
+
10
+
11
+ def test_accepts_package_name() -> None:
12
+ assert package_from_argument("com.example.app") == "com.example.app"
13
+
14
+
15
+ def test_rejects_non_package_argument() -> None:
16
+ try:
17
+ package_from_argument("not a package")
18
+ except RuStoreError:
19
+ return
20
+ raise AssertionError("invalid package name was accepted")
21
+
22
+
23
+ def test_filename_from_url() -> None:
24
+ assert (
25
+ Client.file_name_from_url("https://static-m.rustore.ru/path/app.apk", "fallback.apk")
26
+ == "app.apk"
27
+ )
28
+ assert (
29
+ Client.file_name_from_url("https://static-m.rustore.ru/path/app", "fallback.apk")
30
+ == "app.apk"
31
+ )