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
This commit is contained in:
Jarian 2026-07-05 22:55:30 +00:00
parent 6c9d54b4ab
commit 10fbfb5c5a
3 changed files with 190 additions and 126 deletions

View File

@ -31,7 +31,7 @@ class SettingsManager(private val context: Context) {
context.dataStore.data context.dataStore.data
.catch { throw it } .catch { throw it }
.map { prefs -> .map { prefs ->
prefs[PreferencesKeys.SERVER_PORT] ?: "9800" prefs[PreferencesKeys.SERVER_PORT] ?: "8080"
} }
fun getServerUrlFlow(): Flow<String> = fun getServerUrlFlow(): Flow<String> =

View File

@ -1,10 +1,13 @@
import os import os
import re import re
import json import json
import time
import yaml import yaml
import zipfile import zipfile
import hashlib import hashlib
import shutil import shutil
import threading
import asyncio
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -14,7 +17,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app import AppMetadata, AppListResponse, UpdateCheckRequest, UpdateCheckResponse from app import AppMetadata, AppListResponse, UpdateCheckRequest, UpdateCheckResponse
from scanner import scan_repository, parse_manifest from scanner import scan_repository, parse_manifest, parse_manifest_bytes
# Load config # Load config
config_path = Path(__file__).parent / "config.yaml" config_path = Path(__file__).parent / "config.yaml"
@ -55,25 +58,29 @@ downloads_dir = Path(__file__).parent / "downloads"
downloads_dir.mkdir(exist_ok=True) downloads_dir.mkdir(exist_ok=True)
app.mount("/dl", StaticFiles(directory=str(downloads_dir)), name="downloads") app.mount("/dl", StaticFiles(directory=str(downloads_dir)), name="downloads")
# Cache of scanned apps # Cache of scanned apps (thread-safe with TTL)
_apps_cache: list = [] _apps_cache: list = []
_cache_valid = False _cache_timestamp: float = 0.0
_CACHE_TTL = 300 # 5 minutes
_cache_lock = threading.Lock()
def get_apps() -> list: def get_apps() -> list:
"""Get list of apps, scanning if cache is invalid.""" """Get list of apps, scanning if cache is stale or invalid."""
global _apps_cache, _cache_valid global _apps_cache, _cache_timestamp
if not _cache_valid: with _cache_lock:
_apps_cache = scan_repository(str(REPO_PATH)) if not _apps_cache or (time.time() - _cache_timestamp) > _CACHE_TTL:
_cache_valid = True _apps_cache = scan_repository(str(REPO_PATH))
print(f"Repository scanned: {_apps_cache.__len__()} apps found") _cache_timestamp = time.time()
print(f"Repository scanned: {len(_apps_cache)} apps found")
return _apps_cache return _apps_cache
def invalidate_cache(): def invalidate_cache():
"""Invalidate the apps cache.""" """Invalidate the apps cache."""
global _cache_valid global _cache_timestamp
_cache_valid = False with _cache_lock:
_cache_timestamp = 0
def find_app(app_id: str) -> Optional[dict]: def find_app(app_id: str) -> Optional[dict]:
@ -227,8 +234,12 @@ def get_app_screenshots(app_id: str):
@app.get("/api/apps/{app_id}/screenshots/{filename}") @app.get("/api/apps/{app_id}/screenshots/{filename}")
def get_screenshot(app_id: str, filename: str): def get_screenshot(app_id: str, filename: str):
"""Get a specific screenshot.""" """Get a specific screenshot."""
safe_filename = os.path.basename(filename)
screenshots_dir = REPO_PATH / f"{app_id}_screenshots" screenshots_dir = REPO_PATH / f"{app_id}_screenshots"
screenshot_path = screenshots_dir / filename screenshot_path = (screenshots_dir / safe_filename).resolve()
if not str(screenshot_path).startswith(str(screenshots_dir.resolve())):
raise HTTPException(status_code=400, detail="Invalid path")
if not screenshot_path.exists(): if not screenshot_path.exists():
raise HTTPException(status_code=404, detail="Screenshot not found") raise HTTPException(status_code=404, detail="Screenshot not found")
@ -285,18 +296,22 @@ async def upload_app(
if not apk.filename or not apk.filename.endswith(".apk"): if not apk.filename or not apk.filename.endswith(".apk"):
raise HTTPException(400, "File must be an APK") raise HTTPException(400, "File must be an APK")
contents = await apk.read()
if len(contents) < 4 or contents[:4] != b"PK\x03\x04":
raise HTTPException(400, "File is not a valid APK (invalid ZIP magic bytes)")
target_id = app_id or _sanitize_app_id(apk.filename) target_id = app_id or _sanitize_app_id(apk.filename)
apk_target = REPO_PATH / f"{target_id}.apk" apk_target = REPO_PATH / f"{target_id}.apk"
# Check for duplicate # Check for duplicate
if apk_target.exists(): if apk_target.exists():
existing = parse_manifest(str(apk_target)) existing = parse_manifest(str(apk_target))
new = parse_manifest(str(apk.filename)) new = parse_manifest_bytes(contents)
if existing.get("version_code") == new.get("version_code"): if existing.get("version_code") == new.get("version_code"):
raise HTTPException(409, f"App '{target_id}' already exists with same version") raise HTTPException(409, f"App '{target_id}' already exists with same version")
# Save APK # Save APK
apk_target.write_bytes(await apk.read()) apk_target.write_bytes(contents)
# Save metadata JSON if provided # Save metadata JSON if provided
if metadata and metadata.filename and metadata.filename.endswith(".json"): if metadata and metadata.filename and metadata.filename.endswith(".json"):

View File

@ -1,5 +1,6 @@
import os import os
import re import re
import subprocess
import zipfile import zipfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Dict, List, Optional from typing import Dict, List, Optional
@ -23,9 +24,8 @@ def parse_manifest(apk_path: str) -> Dict:
with zipfile.ZipFile(apk_path) as z: with zipfile.ZipFile(apk_path) as z:
# Check for compiled manifest (binary XML) # Check for compiled manifest (binary XML)
if "AndroidManifest.xml" in z.namelist(): if "AndroidManifest.xml" in z.namelist():
# Binary XML - try to extract what we can # Binary XML - use aapt for reliable parsing
data = z.read("AndroidManifest.xml") metadata = _parse_binary_manifest(apk_path, metadata)
metadata = _parse_binary_manifest(data, metadata)
else: else:
# No manifest found # No manifest found
return metadata return metadata
@ -35,10 +35,11 @@ def parse_manifest(apk_path: str) -> Dict:
apk_name = Path(apk_path).stem apk_name = Path(apk_path).stem
metadata["package_name"] = apk_name metadata["package_name"] = apk_name
# Look for icon resource # Look for icon resource (fallback if aapt didn't find one)
icon_res = _find_icon_resource(apk_path) if not metadata.get("icon"):
if icon_res: icon_res = _find_icon_resource(apk_path)
metadata["icon"] = icon_res if icon_res:
metadata["icon"] = icon_res
except Exception as e: except Exception as e:
print(f"Error parsing {apk_path}: {e}") print(f"Error parsing {apk_path}: {e}")
@ -46,126 +47,174 @@ def parse_manifest(apk_path: str) -> Dict:
return metadata return metadata
def _parse_binary_manifest(data: bytes, metadata: Dict) -> Dict: def parse_manifest_bytes(data: bytes) -> Dict:
"""Parse binary XML manifest. Android uses a proprietary binary XML format.""" """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: try:
# Try to decode string table - the binary XML format has a header import tempfile
# then a string table, then keypool, then resources with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp:
# This is a simplified parser that extracts common fields tmp.write(data)
tmp_path = tmp.name
# Check if it's binary XML (starts with XML header magic) try:
if len(data) < 20: with zipfile.ZipFile(tmp_path) as z:
return metadata if "AndroidManifest.xml" in z.namelist():
metadata = _parse_binary_manifest(tmp_path, metadata)
# Binary XML header magic: 0x00080003 finally:
if data[0:4] == b'\x03\x00\x08\x00' or data[0:4] == b'\x00\x08\x00\x03': os.unlink(tmp_path)
# Try to extract strings from the string table
strings = _extract_strings(data)
# Look for package attribute
for s in strings:
if s and "." in s and not s.startswith("android"):
# Likely a package name
if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2:
metadata["package_name"] = s
break
# Look for versionName
for s in strings:
if s and re.match(r'^\d+\.\d+', s):
metadata["version_name"] = s
break
# Look for versionCode (numeric)
for s in strings:
if s and re.match(r'^\d{1,10}$', s) and len(s) < 10:
# Could be version code or other number
if metadata["version_code"] is None:
metadata["version_code"] = s
break
# Look for app label - usually contains spaces or title-case words
for s in strings:
if s and len(s) > 1 and len(s) < 50 and not s.startswith("android"):
if any(c.isupper() for c in s) or " " in s:
if s != metadata["package_name"]:
metadata["label"] = s
break
except Exception as e: except Exception as e:
print(f"Error parsing binary manifest: {e}") print(f"Error parsing APK bytes: {e}")
return metadata return metadata
def _extract_strings(data: bytes) -> List[str]: def _parse_binary_manifest(data_or_path, metadata: Dict) -> Dict:
"""Extract string table from binary XML.""" """Parse binary XML manifest using aapt for reliable extraction.
strings = []
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: try:
# Binary XML header structure: with tempfile.NamedTemporaryFile(suffix=".apk", delete=False) as tmp:
# 4 bytes: type (0x0008) tmp.write(data_or_path)
# 4 bytes: reserved tmp_path = tmp.name
# 4 bytes: header size
# 4 bytes: end of strings offset
# 4 bytes: start of string data offset
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: if len(data) < 20:
return strings return
header_size = int.from_bytes(data[8:12], 'little') if data[0:4] != b'\x03\x00\x08\x00' and data[0:4] != b'\x00\x08\x00\x03':
strings_end = int.from_bytes(data[12:16], 'little') return
strings_start = int.from_bytes(data[16:20], 'little')
if strings_end <= strings_start or strings_start < header_size: strings = _extract_strings_fallback(data)
return strings
# String count is at offset header_size (right after header) for s in strings:
str_count_offset = header_size if s and "." in s and not s.startswith("android"):
if str_count_offset + 4 > len(data): if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2:
return strings if metadata["package_name"] is None:
metadata["package_name"] = s
break
str_count = int.from_bytes(data[str_count_offset:str_count_offset + 4], 'little') for s in strings:
if s and re.match(r'^\d+\.\d+', s):
# Index table starts after count if metadata["version_name"] is None:
index_offset = str_count_offset + 4 metadata["version_name"] = s
break
# Extract string indices
indices = []
for i in range(min(str_count, 1000)): # limit to avoid issues
idx_off = index_offset + (i * 4)
if idx_off + 4 > len(data):
break
idx = int.from_bytes(data[idx_off:idx_off + 4], 'little')
indices.append(idx)
# Extract strings using indices
for idx in indices:
str_offset = strings_start + idx
if str_offset + 4 > len(data):
continue
# String length is stored as 2 bytes (UTF-16 length)
utf16_len = int.from_bytes(data[str_offset:str_offset + 2], 'little')
# Then 2 bytes for UTF-8 length
utf8_len = int.from_bytes(data[str_offset + 2:str_offset + 4], 'little')
if utf8_len == 0 or utf8_len > 500:
continue
str_data_start = str_offset + 4
if str_data_start + utf8_len > len(data):
continue
try:
s = data[str_data_start:str_data_start + utf8_len].decode('utf-8', errors='ignore')
if s:
strings.append(s)
except Exception:
continue
except Exception as e: except Exception as e:
print(f"Error extracting strings: {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 return strings