diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 2da710a49..bd8970350 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1168,7 +1168,14 @@ const requiredRustHostSnippets = [ 'import_text_file_payload', 'import_image_file_payload', 'import_audio_file_payload', + 'export_image_payload', 'export_audio_payload', + 'detect_image_mime_type', + 'detect_audio_mime_type', + 'ensure_image_bytes_match_mime_type', + 'ensure_audio_bytes_match_mime_type', + '"image bytes do not match MIME"', + '"audio bytes do not match MIME"', 'set_title', 'set_badge_count', 'window.reload()', @@ -1177,6 +1184,9 @@ const requiredRustHostSnippets = [ 'window.theme()', 'WindowEvent::Focused', 'WindowEvent::DragDrop', + 'first_valid_desktop_image_drop_payload', + '.filter(|path| path.is_file() && import_image_mime_type(path).is_some())', + '.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())', 'PageLoadEvent', 'host_bridge_event_script', 'origin: window.location.origin', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs index cf4e476d6..5334e6bd7 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -190,6 +190,68 @@ fn export_audio_extension(mime_type: &str) -> Option<&'static str> { } } +fn riff_container_matches(bytes: &[u8], kind: &[u8; 4]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == kind +} + +fn detect_image_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { + return Some("image/png"); + } + + if bytes.len() >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { + return Some("image/jpeg"); + } + + if riff_container_matches(bytes, b"WEBP") { + return Some("image/webp"); + } + + None +} + +fn detect_audio_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"ID3") + || (bytes.len() >= 2 && bytes[0] == 0xff && (bytes[1] & 0xe0) == 0xe0) + { + return Some("audio/mpeg"); + } + + if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { + return Some("audio/mp4"); + } + + if riff_container_matches(bytes, b"WAVE") { + return Some("audio/wav"); + } + + if bytes.starts_with(b"OggS") { + return Some("audio/ogg"); + } + + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Some("audio/webm"); + } + + None +} + +fn ensure_image_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { + if detect_image_mime_type(bytes) == Some(mime_type) { + Ok(()) + } else { + Err("image bytes do not match MIME".to_string()) + } +} + +fn ensure_audio_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { + if detect_audio_mime_type(bytes) == Some(mime_type) { + Ok(()) + } else { + Err("audio bytes do not match MIME".to_string()) + } +} + fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> String { let mut file_name = normalize_export_file_name(raw_file_name); let extension = export_image_extension(mime_type).unwrap_or("png"); @@ -270,6 +332,9 @@ pub(crate) fn export_image_payload( "image exceeds file export size limit", )); } + ensure_image_bytes_match_mime_type(&bytes, mime_type).map_err(|message| { + failed(request.id.clone(), "invalid_request", message) + })?; let file_name = payload .get("fileName") @@ -334,6 +399,9 @@ pub(crate) fn export_audio_payload( "audio exceeds file export size limit", )); } + ensure_audio_bytes_match_mime_type(&bytes, mime_type).map_err(|message| { + failed(request.id.clone(), "invalid_request", message) + })?; let file_name = payload .get("fileName") @@ -372,6 +440,7 @@ pub(crate) fn import_image_file_payload( if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { return Err("image exceeds import size limit".to_string()); } + ensure_image_bytes_match_mime_type(&bytes, mime_type)?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -413,6 +482,7 @@ pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { return Err("audio exceeds import size limit".to_string()); } + ensure_audio_bytes_match_mime_type(&bytes, mime_type)?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -433,6 +503,38 @@ mod tests { use super::*; use crate::host_bridge::protocol::request; + fn png_bytes() -> Vec { + vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] + } + + fn jpeg_bytes() -> Vec { + vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, b'J', b'F', b'I', b'F'] + } + + fn webp_bytes() -> Vec { + b"RIFF\x04\x00\x00\x00WEBP".to_vec() + } + + fn mp3_bytes() -> Vec { + b"ID3\x04\x00\x00\x00\x00\x00\x10".to_vec() + } + + fn mp4_audio_bytes() -> Vec { + b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00".to_vec() + } + + fn wav_bytes() -> Vec { + b"RIFF\x04\x00\x00\x00WAVE".to_vec() + } + + fn ogg_bytes() -> Vec { + b"OggS\x00\x02audio".to_vec() + } + + fn webm_bytes() -> Vec { + vec![0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00] + } + #[test] fn export_file_name_normalization_rejects_path_like_characters() { assert_eq!( @@ -555,14 +657,14 @@ mod tests { let mut valid = request("file.exportImage"); valid.payload = Some(json!({ "fileName": "分享:卡?.png", - "base64Data": "c2hhcmUtY2FyZA==", + "base64Data": BASE64_STANDARD.encode(png_bytes()), "mimeType": "image/png" })); let (file_name, bytes) = export_image_payload(&valid).expect("image payload"); assert_eq!(file_name, "分享-卡-.png"); - assert_eq!(bytes, b"share-card"); + assert_eq!(bytes, png_bytes()); } #[test] @@ -571,7 +673,7 @@ mod tests { "genarrative-host-bridge-import-{}.png", std::process::id() )); - fs::write(&path, b"image").expect("write import image"); + fs::write(&path, png_bytes()).expect("write import image"); let payload = import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload"); @@ -581,9 +683,9 @@ mod tests { payload["fileName"], path.file_name().unwrap().to_str().unwrap() ); - assert_eq!(payload["base64Data"], "aW1hZ2U="); + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(png_bytes())); assert_eq!(payload["mimeType"], "image/png"); - assert_eq!(payload["bytes"], 5); + assert_eq!(payload["bytes"], png_bytes().len() as u64); assert_eq!(payload["position"], json!({ "x": 12, "y": 24 })); assert!(!payload .to_string() @@ -605,6 +707,17 @@ mod tests { ); fs::remove_file(text_path).expect("remove text file"); + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-disguised-{}.png", + std::process::id() + )); + fs::write(&disguised_path, b"text").expect("write disguised image"); + assert_eq!( + import_image_file_payload(disguised_path.clone(), "selected", None).unwrap_err(), + "image bytes do not match MIME" + ); + fs::remove_file(disguised_path).expect("remove disguised image"); + let large_path = std::env::temp_dir().join(format!( "genarrative-host-bridge-import-large-{}.webp", std::process::id() @@ -627,7 +740,7 @@ mod tests { "genarrative-host-bridge-import-audio-{}.webm", std::process::id() )); - fs::write(&path, b"audio").expect("write import audio"); + fs::write(&path, webm_bytes()).expect("write import audio"); let payload = import_audio_file_payload(path.clone()).expect("payload"); @@ -636,9 +749,9 @@ mod tests { payload["fileName"], path.file_name().unwrap().to_str().unwrap() ); - assert_eq!(payload["base64Data"], "YXVkaW8="); + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(webm_bytes())); assert_eq!(payload["mimeType"], "audio/webm"); - assert_eq!(payload["bytes"], 5); + assert_eq!(payload["bytes"], webm_bytes().len() as u64); assert!(!payload .to_string() .contains(path.to_string_lossy().as_ref())); @@ -659,6 +772,17 @@ mod tests { ); fs::remove_file(text_path).expect("remove text file"); + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-disguised-{}.mp3", + std::process::id() + )); + fs::write(&disguised_path, b"audio").expect("write disguised audio"); + assert_eq!( + import_audio_file_payload(disguised_path.clone()).unwrap_err(), + "audio bytes do not match MIME" + ); + fs::remove_file(disguised_path).expect("remove disguised audio"); + let large_path = std::env::temp_dir().join(format!( "genarrative-host-bridge-import-audio-large-{}.mp3", std::process::id() @@ -680,19 +804,19 @@ mod tests { let mut valid = request("file.exportAudio"); valid.payload = Some(json!({ "fileName": "敲击:音效?.wav", - "base64Data": "YXVkaW8=", + "base64Data": BASE64_STANDARD.encode(wav_bytes()), "mimeType": "audio/wav" })); let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); assert_eq!(file_name, "敲击-音效-.wav"); - assert_eq!(bytes, b"audio"); + assert_eq!(bytes, wav_bytes()); let mut missing_extension = request("file.exportAudio"); missing_extension.payload = Some(json!({ "fileName": "敲击音效", - "base64Data": "YXVkaW8=", + "base64Data": BASE64_STANDARD.encode(webm_bytes()), "mimeType": "audio/webm" })); @@ -706,7 +830,7 @@ mod tests { let mut invalid_mime = request("file.exportAudio"); invalid_mime.payload = Some(json!({ "fileName": "hit.txt", - "base64Data": "YXVkaW8=", + "base64Data": BASE64_STANDARD.encode(wav_bytes()), "mimeType": "text/plain" })); let response = export_audio_payload(&invalid_mime).expect_err("invalid mime"); @@ -734,6 +858,18 @@ mod tests { let error = response.error.expect("error"); assert_eq!(error.code, "invalid_request"); assert_eq!(error.message, "audio exceeds file export size limit"); + + let mut mismatched = request("file.exportAudio"); + mismatched.payload = Some(json!({ + "fileName": "hit.wav", + "base64Data": BASE64_STANDARD.encode(mp3_bytes()), + "mimeType": "audio/wav" + })); + let response = export_audio_payload(&mismatched).expect_err("mismatched audio"); + assert_eq!( + response.error.expect("error").message, + "audio bytes do not match MIME" + ); } #[test] @@ -758,6 +894,18 @@ mod tests { response.error.expect("error").message, "base64Data is invalid" ); + + let mut mismatched = request("file.exportImage"); + mismatched.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": BASE64_STANDARD.encode(jpeg_bytes()), + "mimeType": "image/png" + })); + let response = export_image_payload(&mismatched).expect_err("mismatched image"); + assert_eq!( + response.error.expect("error").message, + "image bytes do not match MIME" + ); } #[test] @@ -794,4 +942,18 @@ mod tests { ); fs::remove_file(path).expect("remove image file"); } + + #[test] + fn detects_allowed_image_and_audio_headers() { + assert_eq!(detect_image_mime_type(&png_bytes()), Some("image/png")); + assert_eq!(detect_image_mime_type(&jpeg_bytes()), Some("image/jpeg")); + assert_eq!(detect_image_mime_type(&webp_bytes()), Some("image/webp")); + assert_eq!(detect_image_mime_type(b"text"), None); + assert_eq!(detect_audio_mime_type(&mp3_bytes()), Some("audio/mpeg")); + assert_eq!(detect_audio_mime_type(&mp4_audio_bytes()), Some("audio/mp4")); + assert_eq!(detect_audio_mime_type(&wav_bytes()), Some("audio/wav")); + assert_eq!(detect_audio_mime_type(&ogg_bytes()), Some("audio/ogg")); + assert_eq!(detect_audio_mime_type(&webm_bytes()), Some("audio/webm")); + assert_eq!(detect_audio_mime_type(b"audio"), None); + } } diff --git a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs index 33c97a9a4..164e7a73c 100644 --- a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs +++ b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs @@ -1,21 +1,25 @@ use crate::host_bridge::files::{import_image_file_payload, import_image_mime_type}; use crate::shell::events::host_bridge_event_script; +use serde_json::Value; use std::path::PathBuf; use tauri::{DragDropEvent, WebviewWindow, WindowEvent}; +fn first_valid_desktop_image_drop_payload( + paths: &[PathBuf], + position: (i32, i32), +) -> Option { + paths + .iter() + .filter(|path| path.is_file() && import_image_mime_type(path).is_some()) + .find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok()) +} + fn emit_desktop_image_drop_event( window: &WebviewWindow, paths: &[PathBuf], position: (i32, i32), ) -> tauri::Result<()> { - let Some(path) = paths - .iter() - .find(|path| path.is_file() && import_image_mime_type(path).is_some()) - .cloned() - else { - return Ok(()); - }; - let Ok(payload) = import_image_file_payload(path, "dropped", Some(position)) else { + let Some(payload) = first_valid_desktop_image_drop_payload(paths, position) else { return Ok(()); }; let script = @@ -33,3 +37,42 @@ pub(crate) fn register_desktop_file_drop_events(window: &WebviewWindow) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn png_bytes() -> Vec { + vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] + } + + #[test] + fn image_drop_skips_disguised_image_before_valid_image() { + let invalid_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-invalid-{}.png", + std::process::id() + )); + let valid_path = std::env::temp_dir().join(format!( + "genarrative-desktop-drop-valid-{}.png", + std::process::id() + )); + fs::write(&invalid_path, b"text").expect("write invalid drop image"); + fs::write(&valid_path, png_bytes()).expect("write valid drop image"); + + let payload = first_valid_desktop_image_drop_payload( + &[invalid_path.clone(), valid_path.clone()], + (7, 9), + ) + .expect("valid image drop payload"); + + assert_eq!( + payload["fileName"], + valid_path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["position"], serde_json::json!({ "x": 7, "y": 9 })); + + fs::remove_file(invalid_path).expect("remove invalid drop image"); + fs::remove_file(valid_path).expect("remove valid drop image"); + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4c4339fe8..446092d8d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -69,6 +69,7 @@ - 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。 - 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 - 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 +- 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。 - 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 72f24f6b8..976fececf 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -340,6 +340,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳声明并实现 `file.importText`,通过系统文件选择框读取用户选择的文本文件,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露本机绝对路径,也不开放通用文件系统。H5 创作 Agent 工作台复用同一 HostBridge 文本导入入口和后端解析接口;普通浏览器、小程序和未声明该能力的裁剪壳继续保留原 `` 路径。 +2026-06-18 追加:桌面壳图片和音频导入 / 导出不再只信扩展名或 H5 声明的 MIME。Tauri Rust 侧会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 bytes 头部识别,导入时要求文件扩展名对应的允许 MIME 与真实 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。 + 2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。 2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Tauri 壳仍只通过 Rust 侧 notification 插件发送即时系统通知,发送前先检查 `permission_state()`,处于 prompt 状态时调用 `request_permission()` 后再复判,最终未授权时返回 `host_error`;桌面 capability 仍只授权 `allow-host-bridge-request`,不开放 notification 插件 JS guest API、远程推送、定时提醒或通知 token;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。