From fe1caff3db6273869983ed57fb7e58e251c8a177 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 09:03:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=85=A5=E5=8F=A3=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面壳 host_bridge_request 入口抽出请求准备阶段 桌面壳单测覆盖非法请求进入 replay 前被拒绝 桌面壳配置门禁和宿主壳文档同步命令入口边界 --- apps/desktop-shell/scripts/check-config.mjs | 17 ++++++++ .../src-tauri/src/host_bridge/mod.rs | 41 ++++++++++++++++++- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f9f581782..e2657477f 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2307,6 +2307,8 @@ const requiredRustHostSnippets = [ 'HostBridgeReplayReservation', 'HOST_BRIDGE_RESPONSE_CACHE_MAX', '.manage(HostBridgeReplayState::default())', + 'fn prepare_host_bridge_request(', + 'prepare_host_bridge_request(&mut request)', 'replay_state.reserve(&request.id)', 'HostBridgeReplayState::wait_for_response', 'execute_host_bridge_request(app, request).await', @@ -2318,6 +2320,21 @@ assertSameList( ['host_bridge_request'], 'shared Tauri HostBridge command', ); + +for (const snippet of [ + 'fn host_bridge_request_rejects_invalid_requests_before_replay()', + 'let replay_state = HostBridgeReplayState::default();', + 'prepare_host_bridge_request(&mut invalid).expect("invalid envelope")', + 'assert_eq!(response.id, "request-1");', + 'assert_eq!(response.error.expect("error").code, "invalid_request");', + 'match replay_state.reserve("request-1")', + 'invalid request must not reserve replay slot', +]) { + if (!rustHostSource.includes(snippet)) { + throw new Error(`desktop shell host_bridge command facade test missing ${snippet}`); + } +} + assertSameList( readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'), expectedRustRootEntries, diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs index 7aa6afe3d..5de749be0 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs @@ -22,16 +22,25 @@ use crate::host_bridge::protocol::{ HostBridgeResponse, }; +fn prepare_host_bridge_request( + request: &mut HostBridgeRequest, +) -> Option { + if let Some(response) = validate_request(request) { + return Some(response); + } + request.id = normalize_request_id(&request.id).unwrap_or_else(|| request.id.clone()); + None +} + #[tauri::command] pub(crate) async fn host_bridge_request( app: tauri::AppHandle, replay_state: tauri::State<'_, HostBridgeReplayState>, mut request: HostBridgeRequest, ) -> Result { - if let Some(response) = validate_request(&request) { + if let Some(response) = prepare_host_bridge_request(&mut request) { return Ok(response); } - request.id = normalize_request_id(&request.id).unwrap_or(request.id); let response = match replay_state.reserve(&request.id) { HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), @@ -43,3 +52,31 @@ pub(crate) async fn host_bridge_request( Ok(response) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + #[test] + fn host_bridge_request_rejects_invalid_requests_before_replay() { + let replay_state = HostBridgeReplayState::default(); + let mut invalid = request("share.open"); + invalid.id = " request-1 ".to_string(); + invalid.bridge = "OtherBridge".to_string(); + + let response = + prepare_host_bridge_request(&mut invalid).expect("invalid envelope"); + + assert!(!response.ok); + assert_eq!(response.id, "request-1"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + match replay_state.reserve("request-1") { + HostBridgeReplayReservation::Execute(_) => {} + HostBridgeReplayReservation::Wait(_) => { + panic!("invalid request must not reserve replay slot") + } + } + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ca6d707ca..a5ca21126 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -132,6 +132,7 @@ - 2026-06-18 桌面壳 capability 最小化:Tauri 主窗口 capability 只授予 `allow-host-bridge-request`,不得授予 `core:default`、`core:*:default`、任意 core 子权限或 dialog / fs / notification / opener / clipboard / deep-link / window-state 等插件权限。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再经 `host_bridge_request` 分发。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 - 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Expo 壳捕获原生异常时只透传共享 `HostBridgeError.code` 白名单内且 `message` 为字符串的协议错误,Tauri 壳的 `failed(...)` 出口也必须先校验同一错误码白名单;未知原生错误对象或非法错误码统一归一为 `host_error` 和固定失败文案,不把 native 私有字段、任意错误码或非字符串 message 回传给 H5。 +- 2026-06-20 桌面 HostBridge command facade 单测边界:Tauri 唯一 `host_bridge_request` command 必须先通过 `prepare_host_bridge_request(...)` 做 envelope、method 和 request id 校验,再进入 `HostBridgeReplayState` reserve / wait / execute;`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs` 的单测必须覆盖非法 envelope 在 replay 前返回 `invalid_request` 且不会占用对应 request id 的 replay slot,桌面配置检查会反查该测试存在。 - 2026-06-20 移动壳协议 helper 单测边界:`apps/mobile-shell/src/host-bridge/protocol.test.ts` 直接覆盖 Expo 移动壳 HostBridge JSON 解析、envelope 和 request id 校验、未知 method 拒绝、ok / failure 响应包装、unsupported / invalid_request 错误构造,以及 native helper 错误归一时只透传共享错误码与字符串 message,不泄露非法错误码、nativeStack 或其它私有字段;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免协议边界只靠完整 bridge 流程间接覆盖。 - 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。 - 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 720170066..369d31f28 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -471,7 +471,7 @@ GameBridge 禁止: 2026-06-19 追加:HostBridge response replay 缓存上限进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_RESPONSE_CACHE_MAX=128`,Expo 移动壳直接导入该常量裁剪已完成响应缓存;Tauri 桌面壳保留 Rust 运行时镜像,但 `apps/desktop-shell/scripts/check-config.mjs` 会反查共享常量并拒绝数值漂移。两端测试都覆盖超过上限后淘汰最早响应,避免 request id 重试缓存无限增长。 -2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 +2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Tauri 唯一 `host_bridge_request` command 入口必须先经过 `prepare_host_bridge_request(...)`,再进入 `HostBridgeReplayState` reserve / wait / execute;非法 envelope 不得占用 replay slot,也不得触发任何宿主能力分发。 2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 97bf5d72a..c254b40cd 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -93,7 +93,7 @@ Tauri 桌面壳的文件能力边界分为两层:`apps/desktop-shell/src-tauri 2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。 -5. 每次新增或调整 native capability、HostBridge event 或宿主上下文 query 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile、事件白名单和 query 契约,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / EAS build config smoke / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。移动壳 EAS build config smoke 必须确认 Android 生产 profile 能产出内部 APK,iOS 生产 smoke profile 只产出 simulator 包,且不写商店提交、签名凭据来源、OTA channel 或本机 dotenv;移动壳 Metro export smoke 必须读取 iOS / Android production bundle,确认最终 bundle 使用共享 HostBridge 契约中的生产 H5 URL,且没有混入本机开发 H5 URL。移动壳和桌面壳文档中的主状态段落能力清单与完整能力清单都必须反查共享 capability profile,避免同一文档内部漂移;桌面壳 `capabilities.rs` 的 Rust 单测必须同时覆盖能力清单顺序、无重复、真实桌面能力完整包含和未接入能力排除;桌面壳未声明的共享 request method 必须由 Rust 测试从 `HOST_BRIDGE_METHODS - capabilities()` 自动派生为 `unsupported_method` 覆盖清单,当前包括 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact`,不得伪造成功;Expo 移动壳未声明的共享 request method 必须由 `HOST_BRIDGE_METHODS - HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 自动派生测试覆盖,确保未接 SDK / 渠道的 method 返回明确 `unsupported_method`;H5 facade 除 `host.getRuntime` 真实回读外,所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,根级门禁会从共享 `HOST_BRIDGE_METHODS` 自动派生需检查清单;排查单端问题时再单独运行 `npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:build-config`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。 +5. 每次新增或调整 native capability、HostBridge event 或宿主上下文 query 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile、事件白名单和 query 契约,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / EAS build config smoke / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。移动壳 EAS build config smoke 必须确认 Android 生产 profile 能产出内部 APK,iOS 生产 smoke profile 只产出 simulator 包,且不写商店提交、签名凭据来源、OTA channel 或本机 dotenv;移动壳 Metro export smoke 必须读取 iOS / Android production bundle,确认最终 bundle 使用共享 HostBridge 契约中的生产 H5 URL,且没有混入本机开发 H5 URL。移动壳和桌面壳文档中的主状态段落能力清单与完整能力清单都必须反查共享 capability profile,避免同一文档内部漂移;桌面壳 `host_bridge_request` command facade 必须先做 request 校验再进入 replay / 分发,且单测覆盖非法 envelope 不占用 replay slot;桌面壳 `capabilities.rs` 的 Rust 单测必须同时覆盖能力清单顺序、无重复、真实桌面能力完整包含和未接入能力排除;桌面壳未声明的共享 request method 必须由 Rust 测试从 `HOST_BRIDGE_METHODS - capabilities()` 自动派生为 `unsupported_method` 覆盖清单,当前包括 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact`,不得伪造成功;Expo 移动壳未声明的共享 request method 必须由 `HOST_BRIDGE_METHODS - HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 自动派生测试覆盖,确保未接 SDK / 渠道的 method 返回明确 `unsupported_method`;H5 facade 除 `host.getRuntime` 真实回读外,所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,根级门禁会从共享 `HOST_BRIDGE_METHODS` 自动派生需检查清单;排查单端问题时再单独运行 `npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:build-config`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。 ## 验收