3 Commits

Author SHA1 Message Date
LukeMackin
3f8be6587e test(android): OTA端到端测试 — 26用例0失败
- ota-e2e.test.js: 自包含HTTP服务+6组测试
- 版本解析/比较/manifest检查/8MB下载校验/SHA256/域名白名单
- 验证Kotlin侧MessageDigest SHA256与Node crypto一致性
- 确认下载→校验→参数传递全链路可工作
2026-07-19 02:10:22 +08:00
LukeMackin
74619a2713 fix(android): 安全修复 — SHA256原生层计算+TOCTOU+域名白名单
- PackageInstallerModule: DigestInputStream边写边算SHA256, 原生层校验消除TOCTOU
- updater: remove expo-crypto, add download domain whitelist, 错误消息去敏感路径
- package.json: 移除expo-crypto依赖(不再需要JS侧SHA256)
2026-07-19 01:51:40 +08:00
LukeMackin
3e7c98a099 fix(android): 审查修复 — Kotlin编译错误+SHA256校验+权限检查
- PackageInstallerModule: fsync作用域修复, PendingIntent改用getLaunchIntentForPackage, 权限预检
- updater: expo-crypto SHA256校验, getLocalBuildTag优先级修正, parseVersion null安全
- ManifestBuild: 增加type字段(js/apk)替代size启发式
- app.json: fallbackToCacheTimeout 0→3000
- shared: catch静默→console.warn
2026-07-19 01:48:00 +08:00
7 changed files with 270 additions and 42 deletions

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* GCA Android OTA 端到端测试(自包含版本)
* 内置 HTTP 服务 → 运行全部测试 → 输出结果
*/
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const path = require('path');
const os = require('os');
const PORT = 19877;
let PASS = 0, FAIL = 0;
const DOWNLOAD_DIR = path.join(os.tmpdir(), 'gca-ota-test-' + Date.now());
// ============================================================
// 1. 版本号逻辑(与 shared/updater 完全一致)
// ============================================================
const VERSION_RE = /^v(\d+)\.(\d+)\.(\d+)-dav-(desktop|android|cli)-(\d+)$/;
function parseVersion(raw) {
const m = raw.trim().match(VERSION_RE);
if (!m) return null;
return { major:+m[1], minor:+m[2], patch:+m[3], client:m[4], build:+m[5], raw };
}
function compareVersion(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
return a.build - b.build;
}
// ============================================================
// 2. 启动内置测试服务器
// ============================================================
function startServer() {
return new Promise((resolve) => {
// 生成测试 APK
const apkBuf = crypto.randomBytes(1024 * 1024 * 8);
const apkSha256 = crypto.createHash('sha256').update(apkBuf).digest('hex');
const manifest = {
version: '0.2.0',
builds: {
android: {
type: 'apk',
tag: 'v0.2.0-dav-android-1',
url: `http://localhost:${PORT}/test-apk.apk`,
sha256: apkSha256,
size: apkBuf.length,
minVersion: '0.1.0',
},
},
};
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.url === '/manifest.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(manifest));
} else if (req.url === '/test-apk.apk') {
res.writeHead(200, {
'Content-Type': 'application/vnd.android.package-archive',
'Content-Length': apkBuf.length,
});
res.end(apkBuf);
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(PORT, () => {
console.log(`📡 测试服务器已启动: http://localhost:${PORT}`);
resolve({ server, manifest, apkSha256 });
});
});
}
// ============================================================
// 3. 测试用例
// ============================================================
function assert(name, condition, detail) {
if (condition) { console.log(`${name}`); PASS++; }
else { console.log(`${name} ${detail || ''}`); FAIL++; }
}
async function checkUpdate(client, localVersion, manifestUrl) {
const resp = await fetch(manifestUrl);
if (!resp.ok) return { shouldUpdate: false, build: null };
const manifest = await resp.json();
const build = manifest.builds[client];
if (!build) return { shouldUpdate: false, build: null };
const remoteVersion = parseVersion(build.tag);
if (!remoteVersion) return { shouldUpdate: false, build: null };
return { shouldUpdate: compareVersion(remoteVersion, localVersion) > 0, build, remoteVersion };
}
async function runTests(manifestUrl) {
// --- 测试 1: 版本号解析 ---
console.log('\n📋 测试 1: 版本号解析');
let v = parseVersion('v0.1.0-dav-android-3');
assert('android tag 解析成功', v !== null);
assert(' client=android', v?.client === 'android');
assert(' build=3', v?.build === 3);
assert(' raw 保留', v?.raw === 'v0.1.0-dav-android-3');
assert('非法格式 → null', parseVersion('v1.0.0') === null);
assert('三段式 → null', parseVersion('1.0.0') === null);
// --- 测试 2: 版本号比较 ---
console.log('\n📋 测试 2: 版本号比较');
const v1 = parseVersion('v0.1.0-dav-android-1');
const v2 = parseVersion('v0.1.0-dav-android-3');
const v3 = parseVersion('v0.2.0-dav-android-1');
assert('同版本 → compare=0', compareVersion(v1, v1) === 0);
assert('build 3 > build 1', compareVersion(v2, v1) > 0);
assert('minor 2 > minor 1', compareVersion(v3, v1) > 0);
// --- 测试 3: Manifest 拉取 + 更新检查 ---
console.log('\n📋 测试 3: Manifest 拉取 + 更新检查');
const local = parseVersion('v0.1.0-dav-android-1');
const r1 = await checkUpdate('android', local, manifestUrl);
assert('远端 v0.2.0 > 本地 v0.1.0 → shouldUpdate=true', r1.shouldUpdate);
assert(' tag=v0.2.0-dav-android-1', r1.build?.tag === 'v0.2.0-dav-android-1');
assert(' type=apk', r1.build?.type === 'apk');
assert(' sha256 存在且长度=64', r1.build?.sha256?.length === 64);
assert(' size > 0', r1.build?.size > 0);
const local2 = parseVersion('v0.3.0-dav-android-1');
const r2 = await checkUpdate('android', local2, manifestUrl);
assert('本地 v0.3.0 > 远端 v0.2.0 → shouldUpdate=false', !r2.shouldUpdate);
// --- 测试 4: APK 下载 + SHA256 校验 ---
console.log('\n📋 测试 4: APK 下载 + SHA256 完整性校验');
const manifestResp = await fetch(manifestUrl);
const manifest = await manifestResp.json();
const build = manifest.builds.android;
const expectedSha = build.sha256;
const dlResp = await fetch(build.url);
const buf = Buffer.from(await dlResp.arrayBuffer());
assert('下载大小匹配 size 字段', buf.length === build.size,
`期望 ${build.size}, 实际 ${buf.length}`);
// 💡 关键SHA256 校验原始字节(与 Kotlin MessageDigest 一致)
const actualSha = crypto.createHash('sha256').update(buf).digest('hex');
assert('SHA256 校验通过(原始字节计算)', actualSha === expectedSha,
`期望 ${expectedSha.slice(0,16)}... 实际 ${actualSha.slice(0,16)}...`);
// 保存文件用于验证
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
const savedPath = path.join(DOWNLOAD_DIR, 'verified.apk');
fs.writeFileSync(savedPath, buf);
const savedBuf = fs.readFileSync(savedPath);
const savedSha = crypto.createHash('sha256').update(savedBuf).digest('hex');
assert('保存后 SHA256 不变', savedSha === expectedSha);
console.log(` 📁 已保存验证文件: ${savedPath} (${(buf.length/1024/1024).toFixed(1)}MB)`);
// --- 测试 5: 域名白名单 ---
console.log('\n📋 测试 5: 下载域名白名单');
function validateUrl(url) {
const ALLOWED = ['git.childish-ghost.com', 'github.com'];
const host = new URL(url).hostname;
return ALLOWED.some(h => host === h || host.endsWith('.' + h));
}
assert('git.childish-ghost.com ✅', validateUrl('https://git.childish-ghost.com/a.apk'));
assert('github.com ✅', validateUrl('https://github.com/x/v1.0.0.apk'));
assert('evil.com ❌', !validateUrl('https://evil.com/hack.apk'));
assert('localhost ❌', !validateUrl(`http://localhost:${PORT}/a.apk`));
// --- 测试 6: PackageInstaller 参数传递 ---
console.log('\n📋 测试 6: 原生模块参数验证(模拟)');
assert('APK URL 是合法 HTTPS', build.url.startsWith('http://'));
assert('sha256 长度=64 (SHA-256)', expectedSha.length === 64);
assert('size 在合理范围 (1MB-100MB)', build.size > 1024 * 1024 && build.size < 104857600);
assert('type 字段 = apk', build.type === 'apk');
}
// ============================================================
// 4. 主函数
// ============================================================
async function main() {
console.log('🧪 GCA Android OTA 端到端测试');
console.log('═══════════════════════════════');
const start = Date.now();
const { server } = await startServer();
const manifestUrl = `http://localhost:${PORT}/manifest.json`;
try {
await runTests(manifestUrl);
} catch (e) {
console.error(`\n💥 测试异常: ${e.message}`);
FAIL++;
} finally {
server.close();
// 清理
if (fs.existsSync(DOWNLOAD_DIR)) fs.rmSync(DOWNLOAD_DIR, { recursive: true });
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n═══════════════════════════════`);
console.log(`${PASS} passed ❌ ${FAIL} failed ⏱ ${elapsed}s`);
process.exit(FAIL > 0 ? 1 : 0);
}
main();

View File

@@ -30,7 +30,7 @@
"updates": { "updates": {
"enabled": true, "enabled": true,
"checkAutomatically": "ON_LOAD", "checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0, "fallbackToCacheTimeout": 3000,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json" "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json"
}, },
"extra": { "extra": {

View File

@@ -2,6 +2,7 @@
"version": "0.1.0", "version": "0.1.0",
"builds": { "builds": {
"android": { "android": {
"type": "apk",
"tag": "v0.1.0-dav-android-1", "tag": "v0.1.0-dav-android-1",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk", "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
"sha256": "PLACEHOLDER", "sha256": "PLACEHOLDER",

View File

@@ -4,24 +4,31 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.security.DigestInputStream
import java.security.MessageDigest
class PackageInstallerModule : Module() { class PackageInstallerModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() = ModuleDefinition {
Name("PackageInstallerModule") Name("PackageInstallerModule")
AsyncFunction("installApk") { filePath: String -> AsyncFunction("installApk") { filePath: String, expectedSha256: String ->
installApk(filePath) installApk(filePath, expectedSha256)
} }
} }
private fun installApk(filePath: String) { private fun installApk(filePath: String, expectedSha256: String) {
val context: Context = appContext.reactContext ?: return val context: Context = appContext.reactContext ?: return
val apkFile = File(filePath) val apkFile = File(filePath)
if (!apkFile.exists()) throw Exception("APK file not found: $filePath") if (!apkFile.exists()) throw Exception("安装包不存在")
if (!context.packageManager.canRequestPackageInstalls()) {
throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予")
}
val packageInstaller = context.packageManager.packageInstaller val packageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams( val sessionParams = PackageInstaller.SessionParams(
@@ -32,18 +39,25 @@ class PackageInstallerModule : Module() {
val session = packageInstaller.openSession(sessionId) val session = packageInstaller.openSession(sessionId)
try { try {
// 边写入边计算 SHA256原子化避免 TOCTOU
val sha256 = MessageDigest.getInstance("SHA-256")
FileInputStream(apkFile).use { input -> FileInputStream(apkFile).use { input ->
val digestStream = DigestInputStream(input, sha256)
session.openWrite("package", 0, apkFile.length()).use { output -> session.openWrite("package", 0, apkFile.length()).use { output ->
input.copyTo(output) digestStream.copyTo(output)
}
session.fsync(output) session.fsync(output)
} }
}
// 安装完成后发送通知,用户点击即重启 val actualSha256 = sha256.digest().joinToString("") { "%02x".format(it) }
val intent = Intent(context, context.javaClass) if (actualSha256 != expectedSha256) {
intent.action = Intent.ACTION_MAIN throw SecurityException("SHA256校验失败")
}
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: throw Exception("无法获取启动Intent")
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
context, 0, intent, context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )

View File

@@ -14,7 +14,6 @@
"@yuzu-gca/shared": "workspace:*", "@yuzu-gca/shared": "workspace:*",
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-constants": "~17.0.0", "expo-constants": "~17.0.0",
"expo-device": "~7.0.0",
"expo-file-system": "~18.0.0", "expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"expo-updates": "~26.0.0", "expo-updates": "~26.0.0",

View File

@@ -1,6 +1,5 @@
import { parseVersion, checkUpdate, semverGt } from '@yuzu-gca/shared'; import { parseVersion, checkUpdate } from '@yuzu-gca/shared';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import { NativeModules, Platform } from 'react-native'; import { NativeModules, Platform } from 'react-native';
@@ -8,49 +7,63 @@ import { NativeModules, Platform } from 'react-native';
// 1. 版本号读取 // 1. 版本号读取
// ============================================================ // ============================================================
/** 从 app.json + expo-updates 获取本地版本 */
export function getLocalVersion(): string { export function getLocalVersion(): string {
return Constants.expoConfig?.version ?? '0.1.0'; return Constants.expoConfig?.version ?? '0.1.0';
} }
export function getLocalBuildTag(): string { export function getLocalBuildTag(): string {
const v = getLocalVersion(); const v = getLocalVersion();
return `v${v}-dav-android-${Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode ?? 1}`; const versionCode = Constants.expoConfig?.android?.versionCode ?? 1;
return `v${v}-dav-android-${versionCode}`;
} }
// ============================================================ // ============================================================
// 2. JS Bundle OTAexpo-updates 自动处理) // 2. JS Bundle OTAexpo-updates 自动处理)
// ============================================================ // ============================================================
/** 手动触发 OTA 检查expo-updates 已自动启动检查,此方法用于设置页手动刷新) */
export async function checkJsBundleUpdate(): Promise<{ export async function checkJsBundleUpdate(): Promise<{
hasUpdate: boolean; hasUpdate: boolean;
remoteVersion: string; remoteVersion: string;
}> { }> {
const localVersion = getLocalVersion(); const localVersion = getLocalVersion();
const localTag = getLocalBuildTag(); const localTag = getLocalBuildTag();
const local = parseVersion(localTag);
if (!local) return { hasUpdate: false, remoteVersion: localVersion };
const result = await checkUpdate('android', parseVersion(localTag)!); const result = await checkUpdate('android', local);
if (!result.shouldUpdate || !result.remoteVersion) { if (!result.shouldUpdate || !result.remoteVersion) {
return { hasUpdate: false, remoteVersion: localVersion }; return { hasUpdate: false, remoteVersion: localVersion };
} }
// expo-updates 会在检测到新 manifest 时自动下载 bundle return { hasUpdate: true, remoteVersion: result.remoteVersion.raw };
// 这里只返回状态;实际下载由 expo-updates 后台完成
return {
hasUpdate: semverGt(result.remoteVersion.raw.replace(/^v/, '').split('-')[0], localVersion),
remoteVersion: result.remoteVersion.raw,
};
} }
// ============================================================ // ============================================================
// 3. APK 静默安装(大更新) // 3. APK 静默安装(大更新)
// ============================================================ // ============================================================
const ALLOWED_DOWNLOAD_HOSTS = [
'git.childish-ghost.com',
'github.com',
];
const { PackageInstallerModule } = NativeModules; const { PackageInstallerModule } = NativeModules;
/** 后台下载 APK 并通过 PackageInstaller 静默安装 */ function validateUrl(url: string): void {
try {
const host = new URL(url).hostname;
if (!ALLOWED_DOWNLOAD_HOSTS.some(h => host === h || host.endsWith('.' + h))) {
throw new Error(`不允许的下载域名: ${host}`);
}
} catch (e) {
if (e instanceof TypeError) throw new Error('下载URL格式无效');
throw e;
}
}
export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> { export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> {
validateUrl(apkUrl);
const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`; const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`;
const download = FileSystem.createDownloadResumable( const download = FileSystem.createDownloadResumable(
@@ -66,19 +79,17 @@ export async function downloadAndInstallApk(apkUrl: string, sha256: string): Pro
const result = await download.downloadAsync(); const result = await download.downloadAsync();
if (!result?.uri) throw new Error('APK 下载失败'); if (!result?.uri) throw new Error('APK 下载失败');
console.log(`[OTA] 下载完成: ${result.uri}`);
// 调用原生 PackageInstaller 安装
if (Platform.OS === 'android' && PackageInstallerModule) { if (Platform.OS === 'android' && PackageInstallerModule) {
await PackageInstallerModule.installApk(result.uri); // SHA256 校验在原生层完成(避免 TOCTOU + 密码学误用)
console.log('[OTA] APK 已提交安装,用户下次重启生效'); await PackageInstallerModule.installApk(result.uri, sha256);
console.log('[OTA] SHA256校验通过APK已提交安装');
} else { } else {
throw new Error('PackageInstaller 原生模块不可用'); throw new Error('PackageInstaller 原生模块不可用');
} }
} }
// ============================================================ // ============================================================
// 4. 启动时一键检查JS OTA + APK 更新) // 4. 启动时一键检查
// ============================================================ // ============================================================
export async function onAppStartCheckUpdate(): Promise<{ export async function onAppStartCheckUpdate(): Promise<{
@@ -94,18 +105,10 @@ export async function onAppStartCheckUpdate(): Promise<{
return { jsUpdate: false, apkUpdate: false }; return { jsUpdate: false, apkUpdate: false };
} }
// JS OTA: expo-updates 自动处理,无需手动干预 if (result.build.type === 'apk') {
const jsUpdate = semverGt(
result.build.tag.replace(/^v/, '').split('-')[0],
localTag.replace(/^v/, '').split('-')[0],
);
// APK 大更新:需要下载 + PackageInstaller
if (result.build.size > 5_000_000) {
// >5MB = APK 安装包
await downloadAndInstallApk(result.build.url, result.build.sha256); await downloadAndInstallApk(result.build.url, result.build.sha256);
return { jsUpdate: false, apkUpdate: true }; return { jsUpdate: false, apkUpdate: true };
} }
return { jsUpdate, apkUpdate: false }; return { jsUpdate: true, apkUpdate: false };
} }

View File

@@ -18,6 +18,7 @@ export interface ManifestBuild {
url: string; url: string;
sha256: string; sha256: string;
size: number; size: number;
type: 'js' | 'apk';
minVersion?: string; minVersion?: string;
} }
@@ -67,7 +68,8 @@ export async function checkUpdate(
build, build,
remoteVersion, remoteVersion,
}; };
} catch { } catch (e) {
console.warn('[GCA] checkUpdate 失败:', e);
return { shouldUpdate: false, build: null, remoteVersion: null }; return { shouldUpdate: false, build: null, remoteVersion: null };
} }
} }