morphe-builder 0.1.0__py3-none-any.whl
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.
- morphe_builder/__init__.py +839 -0
- morphe_builder/apkmirror.py +261 -0
- morphe_builder/build.py +468 -0
- morphe_builder/compatibility.py +450 -0
- morphe_builder/config.py +363 -0
- morphe_builder/merge.py +401 -0
- morphe_builder/module.py +554 -0
- morphe_builder/orchestrator.py +644 -0
- morphe_builder/providers.py +371 -0
- morphe_builder/release.py +409 -0
- morphe_builder/toolchain.py +562 -0
- morphe_builder/uptodown.py +283 -0
- morphe_builder-0.1.0.dist-info/METADATA +298 -0
- morphe_builder-0.1.0.dist-info/RECORD +18 -0
- morphe_builder-0.1.0.dist-info/WHEEL +5 -0
- morphe_builder-0.1.0.dist-info/entry_points.txt +2 -0
- morphe_builder-0.1.0.dist-info/licenses/LICENSE +21 -0
- morphe_builder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import binascii
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from importlib import import_module
|
|
17
|
+
from importlib.metadata import distribution
|
|
18
|
+
from importlib.metadata import version as package_version
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
from xml.etree import ElementTree
|
|
22
|
+
|
|
23
|
+
from .toolchain import (
|
|
24
|
+
APKEDITOR_VERSION,
|
|
25
|
+
APKSIGNER_VERSION,
|
|
26
|
+
SIGNER_VERSION,
|
|
27
|
+
ToolchainError,
|
|
28
|
+
apksigner_jar,
|
|
29
|
+
default_cache_root,
|
|
30
|
+
prepare_toolchain,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ARCHES = ("arm64", "armv7")
|
|
34
|
+
ANDROID_NS = "{http://schemas.android.com/apk/res/android}"
|
|
35
|
+
MANIFEST_NAME = ".gplaydl-integrity.json"
|
|
36
|
+
PROVENANCE_NAME = "provenance.json"
|
|
37
|
+
PACKAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$")
|
|
38
|
+
BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
39
|
+
DIGEST_LENGTHS = {"sha1": 20, "sha256": 32}
|
|
40
|
+
SIGNER_DIGEST_RE = re.compile(
|
|
41
|
+
r"^Signer.*certificate SHA-256 digest:\s*([0-9a-fA-F]{64})$", re.MULTILINE
|
|
42
|
+
)
|
|
43
|
+
MAX_DOWNLOAD_FILES = 20
|
|
44
|
+
MAX_APK_FILE_SIZE = 300 * 1024 * 1024
|
|
45
|
+
MAX_ZIP_ENTRIES = 100_000
|
|
46
|
+
MAX_ZIP_UNCOMPRESSED = 2 * 1024 * 1024 * 1024
|
|
47
|
+
MAX_ZIP_RATIO = 1_000
|
|
48
|
+
# Google rotated both signing keys, so each package has one certificate for
|
|
49
|
+
# Android 7-12 (SDK 24-32) and the rotated one for Android 13+ (SDK 33+).
|
|
50
|
+
EXPECTED_SIGNERS = {
|
|
51
|
+
"com.google.android.youtube": (
|
|
52
|
+
"3d7a1223019aa39d9ea0e3436ab7c0896bfb4fb679f4de5fe7c23f326c8f994a",
|
|
53
|
+
"5aad2bee6db95d17e05a08d7d1e64c10a1511879154483916b6ae6c7fd9cb0c6",
|
|
54
|
+
),
|
|
55
|
+
"com.google.android.apps.youtube.music": (
|
|
56
|
+
"a2a1ad7ba7f41dfca4514e2afeb90691719af6d0fdbed4b09bbf0ed897701ceb",
|
|
57
|
+
"6a2f65ec694a6a632acdcb5080912a565f903d4b8d83f0eb8e44fbdf2660d8e1",
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
ABI_NAMES = {
|
|
61
|
+
"arm64_v8a": "arm64",
|
|
62
|
+
"arm64-v8a": "arm64",
|
|
63
|
+
"armeabi_v7a": "armv7",
|
|
64
|
+
"armeabi-v7a": "armv7",
|
|
65
|
+
}
|
|
66
|
+
DENSITIES = {"ldpi", "mdpi", "tvdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class IntegrityMetadataError(RuntimeError):
|
|
70
|
+
"""Raised when downloaded artifacts cannot be trusted."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GooglePlayAuthUnavailable(RuntimeError):
|
|
74
|
+
"""Raised when Google Play authentication cannot be obtained."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class GooglePlayVersionUnavailable(RuntimeError):
|
|
78
|
+
"""Raised when Google Play cannot serve requested version."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def downloader_commit() -> str:
|
|
82
|
+
"""Return the immutable commit resolved for the installed gplaydl package."""
|
|
83
|
+
raw = distribution("gplaydl").read_text("direct_url.json")
|
|
84
|
+
try:
|
|
85
|
+
commit = json.loads(raw or "")["vcs_info"]["commit_id"]
|
|
86
|
+
except (KeyError, TypeError, json.JSONDecodeError) as error:
|
|
87
|
+
raise IntegrityMetadataError(
|
|
88
|
+
"cannot determine resolved gplaydl commit"
|
|
89
|
+
) from error
|
|
90
|
+
if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-f]{40}", commit):
|
|
91
|
+
raise IntegrityMetadataError("cannot determine resolved gplaydl commit")
|
|
92
|
+
return commit
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
AUTH_UNAVAILABLE_EXIT_CODE = 3
|
|
96
|
+
VERSION_UNAVAILABLE_EXIT_CODE = 4
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_gplaydl(command: list[str]) -> None:
|
|
100
|
+
"""Run pinned gplaydl, separating resumable provider failures.
|
|
101
|
+
|
|
102
|
+
Auth or requested-version unavailability may proceed through fallback
|
|
103
|
+
providers. Every other non-zero exit stays terminal because downloader
|
|
104
|
+
cannot prove artifact trust. Output is never captured into exceptions.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
subprocess.run(command, check=True, timeout=1800)
|
|
108
|
+
except subprocess.CalledProcessError as error:
|
|
109
|
+
if error.returncode == AUTH_UNAVAILABLE_EXIT_CODE:
|
|
110
|
+
raise GooglePlayAuthUnavailable(
|
|
111
|
+
"Google Play authentication is unavailable"
|
|
112
|
+
) from None
|
|
113
|
+
if error.returncode == VERSION_UNAVAILABLE_EXIT_CODE:
|
|
114
|
+
raise GooglePlayVersionUnavailable(
|
|
115
|
+
"Google Play version is unavailable"
|
|
116
|
+
) from None
|
|
117
|
+
raise IntegrityMetadataError("gplaydl command failed") from None
|
|
118
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
119
|
+
raise IntegrityMetadataError("gplaydl command failed") from None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class ApkMetadata:
|
|
124
|
+
path: str
|
|
125
|
+
package: str
|
|
126
|
+
version_name: str
|
|
127
|
+
version_code: str
|
|
128
|
+
split_name: str | None
|
|
129
|
+
split_type: str
|
|
130
|
+
abi: str | None
|
|
131
|
+
density: str | None
|
|
132
|
+
language: str | None
|
|
133
|
+
feature: str | None
|
|
134
|
+
platform_split_types: tuple[str, ...]
|
|
135
|
+
required_split_types: tuple[str, ...]
|
|
136
|
+
required_splits: tuple[str, ...]
|
|
137
|
+
required_features: tuple[str, ...]
|
|
138
|
+
signers_sha256: tuple[str, ...]
|
|
139
|
+
unsupported_requirements: tuple[str, ...] = ()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def gplaydl_command(
|
|
143
|
+
action: str,
|
|
144
|
+
*,
|
|
145
|
+
arch: str,
|
|
146
|
+
package: str | None = None,
|
|
147
|
+
output: Path | None = None,
|
|
148
|
+
manifest: Path | None = None,
|
|
149
|
+
version: str | None = None,
|
|
150
|
+
profile: str | None = None,
|
|
151
|
+
dispenser: str | None = None,
|
|
152
|
+
region: str | None = None,
|
|
153
|
+
) -> list[str]:
|
|
154
|
+
command = [sys.executable, "-m", "gplaydl", action]
|
|
155
|
+
if package:
|
|
156
|
+
command.append(package)
|
|
157
|
+
command.extend(("--arch", arch))
|
|
158
|
+
if output:
|
|
159
|
+
command.extend(("--output", str(output)))
|
|
160
|
+
if manifest:
|
|
161
|
+
command.extend(("--integrity-manifest", str(manifest)))
|
|
162
|
+
if version:
|
|
163
|
+
command.extend(("--version", version))
|
|
164
|
+
if profile:
|
|
165
|
+
command.extend(("--profile", profile))
|
|
166
|
+
if dispenser:
|
|
167
|
+
command.extend(("--dispenser", dispenser))
|
|
168
|
+
if region:
|
|
169
|
+
command.extend(("--country", region))
|
|
170
|
+
if action == "download":
|
|
171
|
+
command.extend(("--splits", "--no-extras"))
|
|
172
|
+
return command
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _decode_digest(value: object, algorithm: str) -> bytes:
|
|
176
|
+
if not isinstance(value, str) or not BASE64URL_RE.fullmatch(value):
|
|
177
|
+
raise IntegrityMetadataError(f"invalid {algorithm} Base64url digest")
|
|
178
|
+
try:
|
|
179
|
+
digest = base64.b64decode(
|
|
180
|
+
value + "=" * (-len(value) % 4), altchars=b"-_", validate=True
|
|
181
|
+
)
|
|
182
|
+
except (binascii.Error, ValueError) as error:
|
|
183
|
+
raise IntegrityMetadataError(f"invalid {algorithm} Base64url digest") from error
|
|
184
|
+
if len(digest) != DIGEST_LENGTHS[algorithm]:
|
|
185
|
+
raise IntegrityMetadataError(f"invalid {algorithm} digest length")
|
|
186
|
+
if base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") != value:
|
|
187
|
+
raise IntegrityMetadataError(f"non-canonical {algorithm} Base64url digest")
|
|
188
|
+
return digest
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_google_manifest(directory: Path, manifest: Path) -> list[dict[str, object]]:
|
|
192
|
+
if not manifest.is_file() or manifest.is_symlink():
|
|
193
|
+
raise IntegrityMetadataError("missing or invalid integrity manifest")
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
196
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
197
|
+
raise IntegrityMetadataError("missing or invalid integrity manifest") from error
|
|
198
|
+
if not isinstance(data, dict) or set(data) != {"version", "files"}:
|
|
199
|
+
raise IntegrityMetadataError("invalid integrity manifest schema")
|
|
200
|
+
if (
|
|
201
|
+
data["version"] != 1
|
|
202
|
+
or isinstance(data["version"], bool)
|
|
203
|
+
or not isinstance(data["files"], list)
|
|
204
|
+
):
|
|
205
|
+
raise IntegrityMetadataError("invalid integrity manifest schema")
|
|
206
|
+
return data["files"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def verify_google_delivery(directory: Path, manifest: Path) -> None:
|
|
210
|
+
"""Revalidate manifest coverage, paths, sizes, and Google-provided digests."""
|
|
211
|
+
entries = read_google_manifest(directory, manifest)
|
|
212
|
+
if not entries or len(entries) > MAX_DOWNLOAD_FILES:
|
|
213
|
+
raise IntegrityMetadataError("invalid Google download file count")
|
|
214
|
+
expected: set[Path] = set()
|
|
215
|
+
for entry in entries:
|
|
216
|
+
if not isinstance(entry, dict) or set(entry) != {
|
|
217
|
+
"path",
|
|
218
|
+
"size",
|
|
219
|
+
"algorithm",
|
|
220
|
+
"digest",
|
|
221
|
+
"google_sha1",
|
|
222
|
+
"google_sha256",
|
|
223
|
+
}:
|
|
224
|
+
raise IntegrityMetadataError("invalid integrity manifest entry")
|
|
225
|
+
relative = entry["path"]
|
|
226
|
+
if (
|
|
227
|
+
not isinstance(relative, str)
|
|
228
|
+
or not relative
|
|
229
|
+
or "\\" in relative
|
|
230
|
+
or Path(relative).is_absolute()
|
|
231
|
+
or any(part in ("", ".", "..") for part in relative.split("/"))
|
|
232
|
+
):
|
|
233
|
+
raise IntegrityMetadataError("invalid integrity manifest path")
|
|
234
|
+
path = directory.joinpath(*relative.split("/"))
|
|
235
|
+
current = directory
|
|
236
|
+
for part in relative.split("/"):
|
|
237
|
+
current /= part
|
|
238
|
+
if current.is_symlink():
|
|
239
|
+
raise IntegrityMetadataError(f"symlink not allowed: {relative}")
|
|
240
|
+
if path in expected:
|
|
241
|
+
raise IntegrityMetadataError("duplicate integrity manifest path")
|
|
242
|
+
expected.add(path)
|
|
243
|
+
size, algorithm = entry["size"], entry["algorithm"]
|
|
244
|
+
if (
|
|
245
|
+
not isinstance(size, int)
|
|
246
|
+
or isinstance(size, bool)
|
|
247
|
+
or size < 0
|
|
248
|
+
or size > MAX_APK_FILE_SIZE
|
|
249
|
+
):
|
|
250
|
+
raise IntegrityMetadataError(f"invalid size for {relative}")
|
|
251
|
+
if not isinstance(algorithm, str) or algorithm not in DIGEST_LENGTHS:
|
|
252
|
+
raise IntegrityMetadataError(f"invalid digest algorithm for {relative}")
|
|
253
|
+
digest = _decode_digest(entry["digest"], algorithm)
|
|
254
|
+
google_digests = {
|
|
255
|
+
"sha1": entry["google_sha1"],
|
|
256
|
+
"sha256": entry["google_sha256"],
|
|
257
|
+
}
|
|
258
|
+
for google_algorithm, encoded in google_digests.items():
|
|
259
|
+
if not isinstance(encoded, str):
|
|
260
|
+
raise IntegrityMetadataError(f"invalid Google digest for {relative}")
|
|
261
|
+
if encoded:
|
|
262
|
+
_decode_digest(encoded, google_algorithm)
|
|
263
|
+
if google_digests[algorithm] != entry["digest"]:
|
|
264
|
+
raise IntegrityMetadataError(f"selected Google digest mismatch: {relative}")
|
|
265
|
+
if not path.is_file() or path.is_symlink():
|
|
266
|
+
raise IntegrityMetadataError(f"missing regular file: {relative}")
|
|
267
|
+
if path.stat().st_size != size:
|
|
268
|
+
raise IntegrityMetadataError(f"size mismatch: {relative}")
|
|
269
|
+
calculated_hash = hashlib.new(algorithm)
|
|
270
|
+
with path.open("rb") as source:
|
|
271
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
272
|
+
calculated_hash.update(chunk)
|
|
273
|
+
calculated = calculated_hash.digest()
|
|
274
|
+
if not hmac.compare_digest(calculated, digest):
|
|
275
|
+
raise IntegrityMetadataError(f"digest mismatch: {relative}")
|
|
276
|
+
actual = {
|
|
277
|
+
path
|
|
278
|
+
for path in directory.rglob("*")
|
|
279
|
+
if path != manifest and (path.is_file() or path.is_symlink())
|
|
280
|
+
}
|
|
281
|
+
if not expected or expected != actual:
|
|
282
|
+
raise IntegrityMetadataError(
|
|
283
|
+
"integrity manifest does not cover downloaded files"
|
|
284
|
+
)
|
|
285
|
+
if any(path.suffix.lower() != ".apk" for path in actual):
|
|
286
|
+
raise IntegrityMetadataError("unexpected non-APK download")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def signer_fingerprints(path: Path) -> tuple[str, ...]:
|
|
290
|
+
"""Return every signer certificate SHA-256 verified by `apksigner`.
|
|
291
|
+
|
|
292
|
+
A rotated signing lineage has one certificate per supported SDK range, and
|
|
293
|
+
all of them are legitimate, so the whole set is returned.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
jar = apksigner_jar()
|
|
297
|
+
except ToolchainError as error:
|
|
298
|
+
raise IntegrityMetadataError(f"cannot verify APK signature: {error}") from error
|
|
299
|
+
try:
|
|
300
|
+
result = subprocess.run(
|
|
301
|
+
["java", "-jar", str(jar), "verify", "--print-certs", "--", str(path)],
|
|
302
|
+
check=False,
|
|
303
|
+
capture_output=True,
|
|
304
|
+
text=True,
|
|
305
|
+
timeout=120,
|
|
306
|
+
)
|
|
307
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
308
|
+
raise IntegrityMetadataError(
|
|
309
|
+
f"APK signature verification failed: {path.name}"
|
|
310
|
+
) from error
|
|
311
|
+
fingerprints = (
|
|
312
|
+
set(SIGNER_DIGEST_RE.findall(result.stdout)) if not result.returncode else set()
|
|
313
|
+
)
|
|
314
|
+
if not fingerprints:
|
|
315
|
+
raise IntegrityMetadataError(
|
|
316
|
+
f"invalid, unsigned, or unsupported APK signature: {path.name}"
|
|
317
|
+
)
|
|
318
|
+
return tuple(sorted(value.lower() for value in fingerprints))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def validate_zip_archive(path: Path, *, required: tuple[str, ...] = ()) -> None:
|
|
322
|
+
try:
|
|
323
|
+
with zipfile.ZipFile(path) as archive:
|
|
324
|
+
entries = archive.infolist()
|
|
325
|
+
if len(entries) > MAX_ZIP_ENTRIES:
|
|
326
|
+
raise IntegrityMetadataError(
|
|
327
|
+
f"archive has too many entries: {path.name}"
|
|
328
|
+
)
|
|
329
|
+
total = 0
|
|
330
|
+
names = set()
|
|
331
|
+
for entry in entries:
|
|
332
|
+
name = entry.filename
|
|
333
|
+
parts = PurePosixPath(name.replace("\\", "/"))
|
|
334
|
+
if (
|
|
335
|
+
not name
|
|
336
|
+
or name.startswith(("/", "\\"))
|
|
337
|
+
or "\\" in name
|
|
338
|
+
or re.match(r"^[A-Za-z]:", name)
|
|
339
|
+
or ".." in parts.parts
|
|
340
|
+
):
|
|
341
|
+
raise IntegrityMetadataError(f"unsafe archive path: {path.name}")
|
|
342
|
+
if (entry.external_attr >> 16) & 0o170000 == 0o120000:
|
|
343
|
+
raise IntegrityMetadataError(
|
|
344
|
+
f"archive contains symlink: {path.name}"
|
|
345
|
+
)
|
|
346
|
+
total += entry.file_size
|
|
347
|
+
if total > MAX_ZIP_UNCOMPRESSED:
|
|
348
|
+
raise IntegrityMetadataError(
|
|
349
|
+
f"archive exceeds uncompressed size limit: {path.name}"
|
|
350
|
+
)
|
|
351
|
+
if entry.file_size and (
|
|
352
|
+
entry.compress_size == 0
|
|
353
|
+
or entry.file_size / entry.compress_size > MAX_ZIP_RATIO
|
|
354
|
+
):
|
|
355
|
+
raise IntegrityMetadataError(
|
|
356
|
+
f"archive compression ratio exceeds limit: {path.name}"
|
|
357
|
+
)
|
|
358
|
+
names.add(name)
|
|
359
|
+
if any(name not in names for name in required):
|
|
360
|
+
raise IntegrityMetadataError(
|
|
361
|
+
f"archive missing required file: {path.name}"
|
|
362
|
+
)
|
|
363
|
+
except zipfile.BadZipFile as error:
|
|
364
|
+
raise IntegrityMetadataError(f"malformed archive: {path.name}") from error
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _manifest_root(path: Path) -> ElementTree.Element:
|
|
368
|
+
try:
|
|
369
|
+
validate_zip_archive(path, required=("AndroidManifest.xml",))
|
|
370
|
+
with zipfile.ZipFile(path) as apk:
|
|
371
|
+
raw = apk.read("AndroidManifest.xml")
|
|
372
|
+
xml = (
|
|
373
|
+
raw
|
|
374
|
+
if raw.lstrip().startswith(b"<")
|
|
375
|
+
else import_module("apkutils2").AXML(raw).get_xml().encode("utf-8")
|
|
376
|
+
)
|
|
377
|
+
return ElementTree.fromstring(xml)
|
|
378
|
+
except Exception as error:
|
|
379
|
+
raise IntegrityMetadataError(
|
|
380
|
+
f"malformed or unsupported APK manifest: {path.name}"
|
|
381
|
+
) from error
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _bool(value: str | None) -> bool:
|
|
385
|
+
return value is not None and value.lower() in {"true", "1"}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def inspect_apk(
|
|
389
|
+
path: Path, relative: str, *, verify_signature: bool = True
|
|
390
|
+
) -> ApkMetadata:
|
|
391
|
+
root = _manifest_root(path)
|
|
392
|
+
package = root.get("package", "")
|
|
393
|
+
version_name = root.get(ANDROID_NS + "versionName", "")
|
|
394
|
+
version_code = root.get(ANDROID_NS + "versionCode", "")
|
|
395
|
+
split_name = root.get("split")
|
|
396
|
+
if (
|
|
397
|
+
not PACKAGE_RE.fullmatch(package)
|
|
398
|
+
or not version_code
|
|
399
|
+
or (not split_name and not version_name)
|
|
400
|
+
):
|
|
401
|
+
raise IntegrityMetadataError(f"missing APK identity metadata: {path.name}")
|
|
402
|
+
config_for = root.get(ANDROID_NS + "configForSplit")
|
|
403
|
+
feature = split_name if _bool(root.get(ANDROID_NS + "isFeatureSplit")) else None
|
|
404
|
+
abi = density = language = None
|
|
405
|
+
if split_name and split_name.startswith("config."):
|
|
406
|
+
qualifier = split_name.removeprefix("config.")
|
|
407
|
+
abi = ABI_NAMES.get(qualifier)
|
|
408
|
+
density = (
|
|
409
|
+
qualifier if qualifier in DENSITIES or qualifier.endswith("dpi") else None
|
|
410
|
+
)
|
|
411
|
+
if (
|
|
412
|
+
not abi
|
|
413
|
+
and not density
|
|
414
|
+
and re.fullmatch(r"[a-z]{2,3}(?:-r[A-Z]{2})?", qualifier)
|
|
415
|
+
):
|
|
416
|
+
language = qualifier
|
|
417
|
+
try:
|
|
418
|
+
with zipfile.ZipFile(path) as apk:
|
|
419
|
+
native_abis = {
|
|
420
|
+
name.split("/", 2)[1]
|
|
421
|
+
for name in apk.namelist()
|
|
422
|
+
if re.match(r"lib/[^/]+/[^/]+\.so$", name)
|
|
423
|
+
}
|
|
424
|
+
except zipfile.BadZipFile as error:
|
|
425
|
+
raise IntegrityMetadataError(f"malformed APK ZIP: {path.name}") from error
|
|
426
|
+
detected = {ABI_NAMES.get(value, value) for value in native_abis}
|
|
427
|
+
if abi and detected and abi not in detected:
|
|
428
|
+
raise IntegrityMetadataError(f"unsupported mixed ABI APK: {path.name}")
|
|
429
|
+
if not abi and len(detected) > 1:
|
|
430
|
+
abi = "universal"
|
|
431
|
+
else:
|
|
432
|
+
abi = abi or (next(iter(detected)) if detected else None)
|
|
433
|
+
application = root.find("application")
|
|
434
|
+
split_type_values = root.get(ANDROID_NS + "splitTypes", "")
|
|
435
|
+
required_type_values = root.get(ANDROID_NS + "requiredSplitTypes", "")
|
|
436
|
+
if application is not None:
|
|
437
|
+
split_type_values += "," + application.get(ANDROID_NS + "splitTypes", "")
|
|
438
|
+
required_type_values += "," + application.get(
|
|
439
|
+
ANDROID_NS + "requiredSplitTypes", ""
|
|
440
|
+
)
|
|
441
|
+
platform_split_types = tuple(
|
|
442
|
+
sorted(value for value in split_type_values.split(",") if value)
|
|
443
|
+
)
|
|
444
|
+
required_split_types = tuple(
|
|
445
|
+
sorted(value for value in required_type_values.split(",") if value)
|
|
446
|
+
)
|
|
447
|
+
required_splits = tuple(
|
|
448
|
+
sorted(
|
|
449
|
+
{
|
|
450
|
+
node.get(ANDROID_NS + "name", "")
|
|
451
|
+
for node in root.findall("uses-split")
|
|
452
|
+
if node.get(ANDROID_NS + "name")
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
required_features = tuple(
|
|
457
|
+
sorted(
|
|
458
|
+
{
|
|
459
|
+
node.get(ANDROID_NS + "name", "")
|
|
460
|
+
for node in root.findall("uses-feature")
|
|
461
|
+
if node.get(ANDROID_NS + "name")
|
|
462
|
+
and node.get(ANDROID_NS + "required", "true").lower() != "false"
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
unsupported_requirements = {
|
|
467
|
+
f"shared-library:{node.get(ANDROID_NS + 'name')}"
|
|
468
|
+
for node in root.findall("./application/uses-library")
|
|
469
|
+
if node.get(ANDROID_NS + "name")
|
|
470
|
+
and node.get(ANDROID_NS + "required", "true").lower() != "false"
|
|
471
|
+
}
|
|
472
|
+
distribution_namespace = "{http://schemas.android.com/apk/distribution}"
|
|
473
|
+
for module in root.findall(f".//{distribution_namespace}module"):
|
|
474
|
+
module_type = module.get(distribution_namespace + "type", "")
|
|
475
|
+
if module_type == "asset-pack":
|
|
476
|
+
unsupported_requirements.add("play-asset-delivery")
|
|
477
|
+
split_type = (
|
|
478
|
+
"base"
|
|
479
|
+
if not split_name
|
|
480
|
+
else "feature"
|
|
481
|
+
if feature
|
|
482
|
+
else "abi"
|
|
483
|
+
if abi
|
|
484
|
+
else "density"
|
|
485
|
+
if density
|
|
486
|
+
else "language"
|
|
487
|
+
if language
|
|
488
|
+
else "neutral"
|
|
489
|
+
)
|
|
490
|
+
return ApkMetadata(
|
|
491
|
+
relative,
|
|
492
|
+
package,
|
|
493
|
+
version_name,
|
|
494
|
+
version_code,
|
|
495
|
+
split_name,
|
|
496
|
+
split_type,
|
|
497
|
+
abi,
|
|
498
|
+
density,
|
|
499
|
+
language,
|
|
500
|
+
feature or config_for,
|
|
501
|
+
platform_split_types,
|
|
502
|
+
required_split_types,
|
|
503
|
+
required_splits,
|
|
504
|
+
required_features,
|
|
505
|
+
signer_fingerprints(path) if verify_signature else (),
|
|
506
|
+
tuple(sorted(unsupported_requirements)),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def verify_apk_set(
|
|
511
|
+
directory: Path,
|
|
512
|
+
package: str,
|
|
513
|
+
*,
|
|
514
|
+
version_name: str | None,
|
|
515
|
+
version_code: str | None,
|
|
516
|
+
arch: str,
|
|
517
|
+
expected_signer: str | None = None,
|
|
518
|
+
) -> list[ApkMetadata]:
|
|
519
|
+
paths = sorted(directory.glob("*.apk"), key=lambda item: item.name)
|
|
520
|
+
metadata = [inspect_apk(path, path.name) for path in paths]
|
|
521
|
+
if not metadata or sum(item.split_type == "base" for item in metadata) != 1:
|
|
522
|
+
raise IntegrityMetadataError("APK set must contain exactly one base APK")
|
|
523
|
+
if any(item.package != package for item in metadata):
|
|
524
|
+
raise IntegrityMetadataError("APK package mismatch")
|
|
525
|
+
base = next(item for item in metadata if item.split_type == "base")
|
|
526
|
+
if (
|
|
527
|
+
any(item.version_code != base.version_code for item in metadata)
|
|
528
|
+
or any(
|
|
529
|
+
item.version_name and item.version_name != base.version_name
|
|
530
|
+
for item in metadata
|
|
531
|
+
)
|
|
532
|
+
or (version_name and base.version_name != version_name)
|
|
533
|
+
or (version_code and base.version_code != version_code)
|
|
534
|
+
):
|
|
535
|
+
raise IntegrityMetadataError("APK version mismatch")
|
|
536
|
+
signers = {item.signers_sha256 for item in metadata}
|
|
537
|
+
if len(signers) != 1:
|
|
538
|
+
raise IntegrityMetadataError("APK signer mismatch")
|
|
539
|
+
pinned = EXPECTED_SIGNERS.get(
|
|
540
|
+
package, (expected_signer,) if expected_signer else ()
|
|
541
|
+
)
|
|
542
|
+
if pinned and set(pinned) != set(metadata[0].signers_sha256):
|
|
543
|
+
raise IntegrityMetadataError("APK signer lineage differs from pinned lineage")
|
|
544
|
+
split_names = {item.split_name for item in metadata if item.split_name}
|
|
545
|
+
missing = sorted(
|
|
546
|
+
{required for item in metadata for required in item.required_splits}
|
|
547
|
+
- split_names
|
|
548
|
+
)
|
|
549
|
+
if missing:
|
|
550
|
+
raise IntegrityMetadataError(f"missing required split: {', '.join(missing)}")
|
|
551
|
+
available_types = {
|
|
552
|
+
split_type for item in metadata for split_type in item.platform_split_types
|
|
553
|
+
}
|
|
554
|
+
missing_types = sorted(
|
|
555
|
+
{split_type for item in metadata for split_type in item.required_split_types}
|
|
556
|
+
- available_types
|
|
557
|
+
)
|
|
558
|
+
if missing_types:
|
|
559
|
+
raise IntegrityMetadataError(
|
|
560
|
+
f"missing required split type: {', '.join(missing_types)}"
|
|
561
|
+
)
|
|
562
|
+
incompatible = sorted(
|
|
563
|
+
{
|
|
564
|
+
item.abi
|
|
565
|
+
for item in metadata
|
|
566
|
+
if item.abi and item.abi not in {arch, "universal"}
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
if incompatible:
|
|
570
|
+
raise IntegrityMetadataError(
|
|
571
|
+
f"wrong ABI split for {arch}: {', '.join(incompatible)}"
|
|
572
|
+
)
|
|
573
|
+
return metadata
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _sha256(path: Path) -> str:
|
|
577
|
+
digest = hashlib.sha256()
|
|
578
|
+
with path.open("rb") as source:
|
|
579
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
580
|
+
digest.update(chunk)
|
|
581
|
+
return digest.hexdigest()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def write_provenance(
|
|
585
|
+
directory: Path,
|
|
586
|
+
manifest: Path,
|
|
587
|
+
metadata: list[ApkMetadata],
|
|
588
|
+
*,
|
|
589
|
+
package: str,
|
|
590
|
+
arch: str,
|
|
591
|
+
profile: str | None,
|
|
592
|
+
region: str | None,
|
|
593
|
+
) -> None:
|
|
594
|
+
google = {
|
|
595
|
+
entry["path"]: entry for entry in read_google_manifest(directory, manifest)
|
|
596
|
+
}
|
|
597
|
+
files = []
|
|
598
|
+
for item in sorted(metadata, key=lambda value: value.path):
|
|
599
|
+
path = directory / item.path
|
|
600
|
+
entry = google[item.path]
|
|
601
|
+
files.append(
|
|
602
|
+
{
|
|
603
|
+
"original_filename": item.path,
|
|
604
|
+
"normalized_filename": item.path,
|
|
605
|
+
"size": path.stat().st_size,
|
|
606
|
+
"google_digest": {
|
|
607
|
+
"algorithm": entry["algorithm"],
|
|
608
|
+
"value": entry["digest"],
|
|
609
|
+
},
|
|
610
|
+
"sha256": _sha256(path),
|
|
611
|
+
"apk": asdict(item),
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
base = next(item for item in metadata if item.split_type == "base")
|
|
615
|
+
version_name, version_code = base.version_name, base.version_code
|
|
616
|
+
provenance = {
|
|
617
|
+
"schema_version": 1,
|
|
618
|
+
"provider": "google-play",
|
|
619
|
+
"downloader": {"name": "gplaydl", "commit": downloader_commit()},
|
|
620
|
+
"inspection_tools": {
|
|
621
|
+
"apkutils2": package_version("apkutils2"),
|
|
622
|
+
"apksigner": APKSIGNER_VERSION,
|
|
623
|
+
},
|
|
624
|
+
"package": package,
|
|
625
|
+
"version": {"name": version_name, "code": version_code},
|
|
626
|
+
"architecture": arch,
|
|
627
|
+
"profile": profile,
|
|
628
|
+
"region": region,
|
|
629
|
+
"certificate_sha256": list(base.signers_sha256),
|
|
630
|
+
"files": files,
|
|
631
|
+
}
|
|
632
|
+
(directory / PROVENANCE_NAME).write_text(
|
|
633
|
+
json.dumps(provenance, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def run(args: argparse.Namespace) -> None:
|
|
638
|
+
if args.action in {"build", "verify", "list-patches", "clean"}:
|
|
639
|
+
from .orchestrator import (
|
|
640
|
+
emit_result,
|
|
641
|
+
run_build,
|
|
642
|
+
run_clean,
|
|
643
|
+
run_list_patches,
|
|
644
|
+
run_verify,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
handlers = {
|
|
648
|
+
"build": run_build,
|
|
649
|
+
"verify": run_verify,
|
|
650
|
+
"list-patches": run_list_patches,
|
|
651
|
+
"clean": run_clean,
|
|
652
|
+
}
|
|
653
|
+
emit_result(handlers[args.action](args), args.json)
|
|
654
|
+
return
|
|
655
|
+
if args.action == "tools":
|
|
656
|
+
provenance = prepare_toolchain(
|
|
657
|
+
args.cache,
|
|
658
|
+
{
|
|
659
|
+
"morphe-cli": args.morphe_version,
|
|
660
|
+
"morphe-patches": args.patches_version,
|
|
661
|
+
"apkeditor": args.apkeditor_version,
|
|
662
|
+
"apk-signer": args.signer_version,
|
|
663
|
+
},
|
|
664
|
+
)
|
|
665
|
+
if args.json:
|
|
666
|
+
print(json.dumps({"provenance": str(provenance)}, sort_keys=True))
|
|
667
|
+
else:
|
|
668
|
+
print(provenance)
|
|
669
|
+
return
|
|
670
|
+
dispenser = args.dispenser or os.environ.get("MORPHE_DISPENSER_URL")
|
|
671
|
+
if dispenser:
|
|
672
|
+
try:
|
|
673
|
+
dispenser = dispenser_url(dispenser)
|
|
674
|
+
except argparse.ArgumentTypeError as error:
|
|
675
|
+
raise IntegrityMetadataError("invalid dispenser URL") from error
|
|
676
|
+
arches = ARCHES if args.arch == "both" else (args.arch,)
|
|
677
|
+
requested_version = getattr(args, "version_code", None) or getattr(
|
|
678
|
+
args, "version_name", None
|
|
679
|
+
)
|
|
680
|
+
if args.action != "download":
|
|
681
|
+
for arch in arches:
|
|
682
|
+
run_gplaydl(
|
|
683
|
+
gplaydl_command(
|
|
684
|
+
args.action,
|
|
685
|
+
arch=arch,
|
|
686
|
+
profile=args.profile,
|
|
687
|
+
dispenser=dispenser,
|
|
688
|
+
region=args.region,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
return
|
|
692
|
+
if args.output.exists():
|
|
693
|
+
raise FileExistsError(f"refusing to overwrite output directory: {args.output}")
|
|
694
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
695
|
+
with tempfile.TemporaryDirectory(
|
|
696
|
+
prefix=f".{args.output.name}-untrusted-", dir=args.output.parent
|
|
697
|
+
) as temporary:
|
|
698
|
+
staging = Path(temporary) / "verified"
|
|
699
|
+
staging.mkdir()
|
|
700
|
+
for arch in arches:
|
|
701
|
+
output = staging / arch if args.arch == "both" else staging
|
|
702
|
+
output.mkdir(exist_ok=True)
|
|
703
|
+
manifest = output / MANIFEST_NAME
|
|
704
|
+
run_gplaydl(
|
|
705
|
+
gplaydl_command(
|
|
706
|
+
"download",
|
|
707
|
+
arch=arch,
|
|
708
|
+
package=args.package,
|
|
709
|
+
output=output,
|
|
710
|
+
manifest=manifest,
|
|
711
|
+
version=requested_version,
|
|
712
|
+
profile=args.profile,
|
|
713
|
+
dispenser=dispenser,
|
|
714
|
+
region=args.region,
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
verify_google_delivery(output, manifest)
|
|
718
|
+
metadata = verify_apk_set(
|
|
719
|
+
output,
|
|
720
|
+
args.package,
|
|
721
|
+
version_name=args.version_name,
|
|
722
|
+
version_code=args.version_code,
|
|
723
|
+
arch=arch,
|
|
724
|
+
expected_signer=getattr(args, "expected_signer", None),
|
|
725
|
+
)
|
|
726
|
+
write_provenance(
|
|
727
|
+
output,
|
|
728
|
+
manifest,
|
|
729
|
+
metadata,
|
|
730
|
+
package=args.package,
|
|
731
|
+
arch=arch,
|
|
732
|
+
profile=args.profile,
|
|
733
|
+
region=args.region,
|
|
734
|
+
)
|
|
735
|
+
if args.output.exists():
|
|
736
|
+
raise FileExistsError(
|
|
737
|
+
f"refusing to overwrite output directory: {args.output}"
|
|
738
|
+
)
|
|
739
|
+
os.replace(staging, args.output)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def package_name(value: str) -> str:
|
|
743
|
+
if not PACKAGE_RE.fullmatch(value):
|
|
744
|
+
raise argparse.ArgumentTypeError(f"invalid Android package name: {value}")
|
|
745
|
+
return value
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def region(value: str) -> str:
|
|
749
|
+
if not re.fullmatch(r"[A-Za-z]{2}", value):
|
|
750
|
+
raise argparse.ArgumentTypeError(f"invalid region: {value}")
|
|
751
|
+
return value.upper()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def dispenser_url(value: str) -> str:
|
|
755
|
+
parsed = urlparse(value)
|
|
756
|
+
if (
|
|
757
|
+
len(value) > 4096
|
|
758
|
+
or parsed.scheme != "https"
|
|
759
|
+
or not parsed.hostname
|
|
760
|
+
or parsed.username is not None
|
|
761
|
+
or parsed.password is not None
|
|
762
|
+
or parsed.query
|
|
763
|
+
or parsed.fragment
|
|
764
|
+
):
|
|
765
|
+
raise argparse.ArgumentTypeError("invalid dispenser URL")
|
|
766
|
+
return value
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def parser() -> argparse.ArgumentParser:
|
|
770
|
+
root = argparse.ArgumentParser(
|
|
771
|
+
description="Build verified Morphe APKs and modules from trusted stock inputs."
|
|
772
|
+
)
|
|
773
|
+
commands = root.add_subparsers(dest="action", required=True)
|
|
774
|
+
for action in ("auth", "download"):
|
|
775
|
+
command = commands.add_parser(action)
|
|
776
|
+
command.add_argument("--arch", choices=(*ARCHES, "both"), default="arm64")
|
|
777
|
+
command.add_argument("--profile")
|
|
778
|
+
command.add_argument("--region", type=region)
|
|
779
|
+
command.add_argument("--dispenser", type=dispenser_url)
|
|
780
|
+
download = commands.choices["download"]
|
|
781
|
+
download.add_argument("package", type=package_name)
|
|
782
|
+
download.add_argument("--output", type=Path, default=Path("downloads"))
|
|
783
|
+
versions = download.add_mutually_exclusive_group()
|
|
784
|
+
versions.add_argument("--version-name")
|
|
785
|
+
versions.add_argument("--version-code")
|
|
786
|
+
tools = commands.add_parser(
|
|
787
|
+
"tools", help="prepare reproducible external build tools"
|
|
788
|
+
)
|
|
789
|
+
tools.add_argument("--cache", type=Path, default=default_cache_root())
|
|
790
|
+
tools.add_argument("--morphe-version", default="latest")
|
|
791
|
+
tools.add_argument("--patches-version", default="latest")
|
|
792
|
+
tools.add_argument("--apkeditor-version", default=APKEDITOR_VERSION)
|
|
793
|
+
tools.add_argument("--signer-version", default=SIGNER_VERSION)
|
|
794
|
+
tools.add_argument("--json", action="store_true")
|
|
795
|
+
|
|
796
|
+
build = commands.add_parser("build", help="build all configured artifacts")
|
|
797
|
+
build.add_argument("config", type=Path)
|
|
798
|
+
build.add_argument("--cache", type=Path, default=default_cache_root())
|
|
799
|
+
build.add_argument("--output", type=Path, default=Path("build"))
|
|
800
|
+
build.add_argument("--keystore", type=Path, required=True)
|
|
801
|
+
build.add_argument("--keystore-alias", default="morphe-builder")
|
|
802
|
+
build.add_argument("--json", action="store_true")
|
|
803
|
+
|
|
804
|
+
verify = commands.add_parser("verify", help="verify an existing APK split set")
|
|
805
|
+
verify.add_argument("directory", type=Path)
|
|
806
|
+
verify.add_argument("package", type=package_name)
|
|
807
|
+
verify.add_argument("--arch", choices=ARCHES, required=True)
|
|
808
|
+
verify.add_argument("--version-name")
|
|
809
|
+
verify.add_argument("--version-code")
|
|
810
|
+
verify.add_argument("--json", action="store_true")
|
|
811
|
+
|
|
812
|
+
patches = commands.add_parser(
|
|
813
|
+
"list-patches", help="resolve configured patches and compatible versions"
|
|
814
|
+
)
|
|
815
|
+
patches.add_argument("config", type=Path)
|
|
816
|
+
patches.add_argument("--cache", type=Path, default=default_cache_root())
|
|
817
|
+
patches.add_argument("--json", action="store_true")
|
|
818
|
+
|
|
819
|
+
clean = commands.add_parser("clean", help="remove only disposable work cache")
|
|
820
|
+
clean.add_argument("--cache", type=Path, default=default_cache_root())
|
|
821
|
+
clean.add_argument("--json", action="store_true")
|
|
822
|
+
return root
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def main() -> None:
|
|
826
|
+
try:
|
|
827
|
+
run(parser().parse_args())
|
|
828
|
+
except Exception as error:
|
|
829
|
+
from .orchestrator import error_code, redact
|
|
830
|
+
|
|
831
|
+
code = error_code(error)
|
|
832
|
+
if code is None:
|
|
833
|
+
raise
|
|
834
|
+
print(f"error: {redact(str(error))}", file=sys.stderr)
|
|
835
|
+
raise SystemExit(code) from error
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
if __name__ == "__main__":
|
|
839
|
+
main()
|