From 9fcba9f4a5a1f770549617c98c3c8dff3528195d Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 03:04:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AF=BC=E5=85=A5=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳文本和音频导入读取前校验可信文件大小 补充缺少 picker size 时的 File.size 回归测试 扩展移动壳配置门禁防止绕过读前大小检查 更新 HostBridge 载荷边界文档和共享决策 --- apps/mobile-shell/scripts/check-config.mjs | 45 ++++++++++ .../src/host-bridge/bridge.test.ts | 82 +++++++++++++++++++ apps/mobile-shell/src/host-bridge/files.ts | 54 +++++++++--- .../shared-memory/decision-log.md | 2 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 6 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 4 +- 6 files changed, 175 insertions(+), 18 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 4085e93c8..a9c5d9ff4 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -195,6 +195,33 @@ function extractMobileBridgeHandledMethods(source) { return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]); } +function extractFunctionBody(source, functionName) { + const start = source.indexOf(`function ${functionName}`); + if (start === -1) { + throw new Error(`unable to read function ${functionName}`); + } + + const openBrace = source.indexOf('{', start); + if (openBrace === -1) { + throw new Error(`unable to read function body ${functionName}`); + } + + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + const character = source[index]; + if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(openBrace + 1, index); + } + } + } + + throw new Error(`unable to read complete function body ${functionName}`); +} + function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) { const dependencySections = [ 'dependencies', @@ -559,6 +586,24 @@ for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) { } } +if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) { + throw new Error('mobile shell must centralize imported file size checks'); +} + +for (const [functionName, readCall] of [ + ['importTextFile', 'file.text()'], + ['importAudioFile', 'file.base64()'], +]) { + const functionBody = extractFunctionBody(hostBridgeSource, functionName); + const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit('); + const readIndex = functionBody.indexOf(readCall); + if (sizeCheckIndex === -1 || readIndex === -1 || sizeCheckIndex > readIndex) { + throw new Error( + `mobile shell ${functionName} must check file size before ${readCall}`, + ); + } +} + for (const profileSource of [ 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index b0f007779..9228cae39 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -79,6 +79,9 @@ vi.mock('expo-clipboard', () => ({ const fileTexts = vi.hoisted(() => new Map()); const fileBase64Data = vi.hoisted(() => new Map()); +const fileSizes = vi.hoisted(() => new Map()); +const fileTextReads = vi.hoisted(() => [] as string[]); +const fileBase64Reads = vi.hoisted(() => [] as string[]); const writtenFiles = vi.hoisted( () => @@ -109,11 +112,17 @@ vi.mock('expo-file-system', () => ({ }); } + get size() { + return fileSizes.get(this.uri) ?? null; + } + text() { + fileTextReads.push(this.uri); return Promise.resolve(fileTexts.get(this.uri) ?? ''); } base64() { + fileBase64Reads.push(this.uri); return Promise.resolve(fileBase64Data.get(this.uri) ?? ''); } }, @@ -328,6 +337,9 @@ afterEach(() => { vi.mocked(Share.share).mockReset(); fileTexts.clear(); fileBase64Data.clear(); + fileSizes.clear(); + fileTextReads.length = 0; + fileBase64Reads.length = 0; writtenFiles.length = 0; resetMobileHostBridgeForTest(); }); @@ -1000,6 +1012,41 @@ describe('handleMobileHostBridgeMessage', () => { }); }); + test('file.importText 在系统选择结果缺少 size 时先用文件大小门禁', async () => { + fileTexts.set('file:///private/mobile/story.md', '暖灯猫街'); + fileSizes.set('file:///private/mobile/story.md', 12); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.md', + name: 'story.md', + mimeType: 'text/markdown', + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importText')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: 'story.md', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }); + expect(fileTextReads).toEqual(['file:///private/mobile/story.md']); + + fileSizes.set('file:///private/mobile/story.md', 5 * 1024 * 1024 + 1); + fileTextReads.length = 0; + + const oversized = await send(request('file.importText')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(fileTextReads).toEqual([]); + }); + test('file.importText 取消选择时返回 cancelled', async () => { vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ canceled: true, @@ -1468,6 +1515,41 @@ describe('handleMobileHostBridgeMessage', () => { }); }); + test('file.importAudio 在系统选择结果缺少 size 时先用文件大小门禁', async () => { + fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64); + fileSizes.set('file:///private/mobile/hit.webm', 6); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: 'hit.webm', + mimeType: 'audio/webm', + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importAudio')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: 'hit.webm', + base64Data: WEBM_BASE64, + mimeType: 'audio/webm', + bytes: 6, + }); + expect(fileBase64Reads).toEqual(['file:///private/mobile/hit.webm']); + + fileSizes.set('file:///private/mobile/hit.webm', 20 * 1024 * 1024 + 1); + fileBase64Reads.length = 0; + + const oversized = await send(request('file.importAudio')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(fileBase64Reads).toEqual([]); + }); + test('file.importAudio 允许系统 MIME 缺失时按扩展名和 bytes 校验导入', async () => { fileBase64Data.set('file:///private/mobile/hit.m4a', MP3_BASE64); vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index e2bf626e8..8377cdc51 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -294,6 +294,36 @@ function normalizeImportedTextMimeType( return null; } +function assertImportedFileSizeWithinLimit( + pickerSize: unknown, + file: File, + maxBytes: number, + message: string, +) { + if ( + typeof pickerSize === 'number' && + Number.isFinite(pickerSize) && + pickerSize > 0 && + pickerSize <= maxBytes + ) { + return; + } + + if (typeof pickerSize === 'number') { + throw invalidRequest(message); + } + + const fileSize = file.size; + if ( + typeof fileSize !== 'number' || + !Number.isFinite(fileSize) || + fileSize <= 0 || + fileSize > maxBytes + ) { + throw invalidRequest(message); + } +} + export async function importTextFile(): Promise { const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, @@ -319,14 +349,14 @@ export async function importTextFile(): Promise { if (!mimeType) { throw invalidRequest('mimeType must be an allowed text type'); } - if ( - typeof asset.size === 'number' && - (asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) - ) { - throw invalidRequest('text exceeds file import size limit'); - } const file = new File(asset.uri); + assertImportedFileSizeWithinLimit( + asset.size, + file, + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + 'text exceeds file import size limit', + ); const content = await file.text(); const bytes = utf8ByteLength(content); if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) { @@ -636,14 +666,14 @@ export async function importAudioFile(): Promise { if (!mimeType) { throw invalidRequest('mimeType must be an allowed audio type'); } - if ( - typeof asset.size === 'number' && - (asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES) - ) { - throw invalidRequest('audio exceeds file import size limit'); - } const file = new File(asset.uri); + assertImportedFileSizeWithinLimit( + asset.size, + file, + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + 'audio exceeds file import size limit', + ); const base64Data = normalizedBase64Data(await file.base64()); if (!base64Data) { throw invalidRequest('base64Data is required'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a364bd30..c01c82e53 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2519,7 +2519,7 @@ ## 2026-06-19 HostBridge 载荷边界单一来源 - 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。 -- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importAudio` 必须在读取文本内容或音频 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 9600b03ec..65db2acb2 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -286,7 +286,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.captureImage`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `