Fix the cross-provider review's findings

- APK reader: cap AndroidManifest.xml, resources.arsc and icon sizes before
  inflating them (APKs can come from install links), and follow resource
  references without cycles and with a result budget.
- Sideloading: reserve devkit-steam (SteamOS's sideloaded-client trampoline);
  the install dialog warns when a name replaces an installed title; a
  manifest's exe may name the program as it is in the archive, above the
  folder the installer steps into.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 22:35:16 +10:00
1 parent 1580dae42e
commit 39d28790a1
5 files changed
+79 -12

No files matched your search

+27 -8
View File
@@ -12,6 +12,12 @@ import zipfile
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
# APKs can come from websites (install links), so nothing read from one may be
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
MAX_MANIFEST = 16 * 1024**2
MAX_ARSC = 128 * 1024**2 # real ones are a few MB; the largest apps' tens of MB
MAX_ICON = 8 * 1024**2
MAX_VALUES = 256 # resolved values per reference, across all its hops
class ApkError(Exception):
@@ -129,15 +135,21 @@ class Resources:
resid = (pid << 24) | (tid << 16) | index
self.entries.setdefault(resid, []).append((language, density, dtype, value))
def values(self, resid, depth=0):
"""[(language, density, type, data)] with references followed."""
def values(self, resid, depth=0, seen=frozenset()):
"""[(language, density, type, data)] with references followed, at most
MAX_VALUES of them and never round a cycle."""
out = []
seen = seen | {resid}
for lang, dens, dtype, value in self.entries.get(resid, []):
if len(out) >= MAX_VALUES:
break
if dtype == T_REF and depth < 5:
out += [(lang or l2, dens or d2, t2, v2) for l2, d2, t2, v2 in self.values(value, depth + 1)]
if value not in seen:
out += [(lang or l2, dens or d2, t2, v2)
for l2, d2, t2, v2 in self.values(value, depth + 1, seen)]
else:
out.append((lang, dens, dtype, value))
return out
return out[:MAX_VALUES]
def string(self, dtype, value):
return self.strings[value] if dtype == T_STRING and value < len(self.strings) else None
@@ -171,6 +183,13 @@ def _icons(attr, res):
return [s for _, s in sorted(vals, key=lambda x: -x[0]) if s]
def _read(z, name, limit):
size = z.getinfo(name).file_size
if size > limit:
raise ApkError(f'{name} in the APK is {size / 1024**2:.0f} MB, more than a real one ({limit // 1024**2} MB)')
return z.read(name)
def apk_info(path):
"""Package, label, version, min_sdk, abis and the best PNG icon inside the APK."""
try:
@@ -182,8 +201,8 @@ def apk_info(path):
if 'AndroidManifest.xml' not in names:
raise ApkError('not an APK: no AndroidManifest.xml')
try:
elements = manifest_elements(z.read('AndroidManifest.xml'))
res = Resources(z.read('resources.arsc') if 'resources.arsc' in names else b'')
elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST))
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
except (struct.error, IndexError, zipfile.BadZipFile) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
@@ -212,11 +231,11 @@ def apk_info(path):
def _icon_png(z, names, icons):
for icon in icons:
if icon.endswith('.png') and icon in names:
return z.read(icon)
return _read(z, icon, MAX_ICON)
# Adaptive icons are XML; fall back to the largest launcher PNG.
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
return z.read(pngs[-1]) if pngs else None
return _read(z, pngs[-1], MAX_ICON) if pngs else None
if __name__ == '__main__':