app-store/server/scanner.py
Jarian 10fbfb5c5a fix: batch fix all open issues
- scanner.py: replace heuristic binary manifest parser with aapt/aapt2
  (fixes #2, #7)
- main.py: add ZIP magic bytes validation on APK upload (fixes #8)
- main.py: sanitize screenshot filename with os.path.basename +
  resolve() check (fixes #6)
- main.py: add threading.Lock + TTL to in-memory cache (fixes #4, #9)
- SettingsManager.kt: default port 9800 -> 8080 to match config.yaml
  (fixes #3)
- main.py: parse_manifest_bytes for in-memory APK validation on upload
2026-07-05 22:55:30 +00:00

282 lines
9.0 KiB
Python

import os
import re
import subprocess
import zipfile
import xml.etree.ElementTree as ET
from typing import Dict, List, Optional
from pathlib import Path
def parse_manifest(apk_path: str) -> Dict:
"""Extract metadata from an APK by reading AndroidManifest.xml directly."""
metadata = {
"package_name": None,
"version_name": None,
"version_code": None,
"label": None,
"icon": None,
"min_sdk": None,
"target_sdk": None,
"permissions": [],
}
try:
with zipfile.ZipFile(apk_path) as z:
# Check for compiled manifest (binary XML)
if "AndroidManifest.xml" in z.namelist():
# Binary XML - use aapt for reliable parsing
metadata = _parse_binary_manifest(apk_path, metadata)
else:
# No manifest found
return metadata
# Extract package name from filename if not found in manifest
if not metadata["package_name"]:
apk_name = Path(apk_path).stem
metadata["package_name"] = apk_name
# Look for icon resource (fallback if aapt didn't find one)
if not metadata.get("icon"):
icon_res = _find_icon_resource(apk_path)
if icon_res:
metadata["icon"] = icon_res
except Exception as e:
print(f"Error parsing {apk_path}: {e}")
return metadata
def parse_manifest_bytes(data: bytes) -> Dict:
"""Extract metadata from APK bytes (for in-memory validation)."""
metadata = {
"package_name": None,
"version_name": None,
"version_code": None,
"label": None,
"icon": None,
"min_sdk": None,
"target_sdk": None,
"permissions": [],
}
try:
import tempfile
with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp:
tmp.write(data)
tmp_path = tmp.name
try:
with zipfile.ZipFile(tmp_path) as z:
if "AndroidManifest.xml" in z.namelist():
metadata = _parse_binary_manifest(tmp_path, metadata)
finally:
os.unlink(tmp_path)
except Exception as e:
print(f"Error parsing APK bytes: {e}")
return metadata
def _parse_binary_manifest(data_or_path, metadata: Dict) -> Dict:
"""Parse binary XML manifest using aapt for reliable extraction.
Accepts either a file path (str) or raw APK bytes.
"""
import tempfile
if isinstance(data_or_path, str):
# Already a file path, use directly with aapt
result = _parse_with_aapt(data_or_path)
if result:
metadata.update(result)
return metadata
# Raw bytes - write temp APK for aapt
try:
with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp:
tmp.write(data_or_path)
tmp_path = tmp.name
result = _parse_with_aapt(tmp_path)
os.unlink(tmp_path)
if result:
metadata.update(result)
except Exception as e:
print(f"Error parsing binary manifest with aapt: {e}")
# Fallback: basic string extraction if aapt fails
_parse_fallback(data_or_path, metadata)
return metadata
def _parse_with_aapt(apk_path: str) -> Optional[Dict]:
"""Use aapt or aapt2 to extract manifest info reliably."""
for tool in ["aapt2", "aapt"]:
try:
result = subprocess.run(
[tool, "dump", "badging", apk_path],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
return _parse_aapt_output(result.stdout)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return None
def _parse_aapt_output(output: str) -> Dict:
"""Parse aapt dump badging output."""
result = {
"package_name": None,
"version_name": None,
"version_code": None,
"label": None,
"icon": None,
"min_sdk": None,
"target_sdk": None,
"permissions": [],
}
for line in output.splitlines():
# Package: name='com.example.app'
m = re.search(r"package: name='([^']+)'", line)
if m:
result["package_name"] = m.group(1)
# versionName='1.0.0'
m = re.search(r"versionName='([^']+)'", line)
if m:
result["version_name"] = m.group(1)
# versionCode='1'
m = re.search(r"versionCode='([^']+)'", line)
if m:
result["version_code"] = m.group(1)
# application-label:'My App'
m = re.search(r"application-label:'([^']*)'", line)
if m:
result["label"] = m.group(1)
# application-icon-128:'res/path'
m = re.search(r"application-icon-\d+:'([^']+)'", line)
if m:
result["icon"] = m.group(1)
# sdkVersion:'21' / minSdkVersion:'21' / targetSdkVersion:'33'
m = re.search(r"minSdkVersion='([^']+)'", line)
if m:
result["min_sdk"] = m.group(1)
m = re.search(r"targetSdkVersion='([^']+)'", line)
if m:
result["target_sdk"] = m.group(1)
# uses-permission: name='android.permission.INTERNET'
m = re.search(r"uses-permission: name='([^']+)'", line)
if m:
result["permissions"].append(m.group(1))
return result
def _parse_fallback(data: bytes, metadata: Dict) -> None:
"""Fallback string-based extraction when aapt is unavailable."""
try:
if len(data) < 20:
return
if data[0:4] != b'\x03\x00\x08\x00' and data[0:4] != b'\x00\x08\x00\x03':
return
strings = _extract_strings_fallback(data)
for s in strings:
if s and "." in s and not s.startswith("android"):
if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2:
if metadata["package_name"] is None:
metadata["package_name"] = s
break
for s in strings:
if s and re.match(r'^\d+\.\d+', s):
if metadata["version_name"] is None:
metadata["version_name"] = s
break
except Exception as e:
print(f"Error in fallback manifest parsing: {e}")
def _extract_strings_fallback(data: bytes) -> List[str]:
"""Extract printable strings >= 4 chars from binary data."""
strings = []
current = []
for byte in data:
if 32 <= byte < 127:
current.append(chr(byte))
else:
if len(current) >= 4:
strings.append(''.join(current))
current = []
if len(current) >= 4:
strings.append(''.join(current))
return strings
def _find_icon_resource(apk_path: str) -> Optional[str]:
"""Find icon resource path in APK."""
try:
with zipfile.ZipFile(apk_path) as z:
names = z.namelist()
# Look for icon in res/drawable-* folders
for name in names:
if "ic_launcher" in name and name.endswith(".png"):
return name
if "icon" in name and name.endswith(".png"):
return name
except Exception:
pass
return None
def scan_repository(repo_path: str) -> List[Dict]:
"""Scan repository directory for APKs and return metadata."""
apps = []
repo_dir = Path(repo_path)
if not repo_dir.exists():
return apps
for apk_file in sorted(repo_dir.glob("*.apk")):
metadata = parse_manifest(str(apk_file))
if not metadata["package_name"]:
continue
app = {
"id": apk_file.stem,
"package_name": metadata["package_name"],
"version_name": str(metadata["version_name"]) if metadata["version_name"] else "unknown",
"version_code": str(metadata["version_code"]) if metadata["version_code"] else "0",
"name": metadata["label"] or apk_file.stem,
"description": "",
"icon": metadata["icon"],
"size": apk_file.stat().st_size,
"file_path": str(apk_file),
"min_sdk": str(metadata["min_sdk"]) if metadata["min_sdk"] else None,
"target_sdk": str(metadata["target_sdk"]) if metadata["target_sdk"] else None,
"permissions": metadata["permissions"],
}
# Check for README or info file alongside APK
info_file = repo_dir / f"{apk_file.stem}.json"
if info_file.exists():
import json
with open(info_file) as f:
info = json.load(f)
for key in ("name", "description", "icon", "package_name",
"version_name", "version_code", "min_sdk",
"target_sdk", "screenshots"):
if key in info:
app[key] = str(info[key]) if key in ("version_name", "version_code", "min_sdk", "target_sdk") else info[key]
if "permissions" in info:
app["permissions"] = info["permissions"]
apps.append(app)
return apps