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.
@@ -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()