diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b5dc0be7e..509f1b77a 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2586,15 +2586,23 @@ const requiredRustHostSnippets = [ 'file_import_cancelled_response', 'file_export_unavailable_response', 'file_import_unavailable_response', + 'file_import_payload_error_response', 'log_desktop_file_export_failure', 'log_desktop_file_import_failure', 'desktop file export failed for {label}', 'desktop file import failed for {label}', + 'ImportFilePayloadError::NativeRead', + 'ImportFilePayloadError::InvalidRequest', 'log_desktop_file_export_failure("path.convert")', 'log_desktop_file_import_failure("path.convert")', 'log_desktop_file_export_failure("write.text")', 'log_desktop_file_export_failure("write.image")', 'log_desktop_file_export_failure("write.audio")', + 'file_import_payload_error_response(request, "read.text", error)', + 'file_import_payload_error_response(request, "read.document", error)', + 'file_import_payload_error_response(request, "read.image", error)', + 'file_import_payload_error_response(request, "read.audio", error)', + 'log_desktop_file_import_failure(label)', 'log_desktop_file_import_failure("read.text.join")', 'log_desktop_file_import_failure("read.document.join")', 'log_desktop_file_import_failure("read.image.join")', @@ -2603,11 +2611,16 @@ const requiredRustHostSnippets = [ 'desktop_file_unavailable_responses_are_stable', 'desktop_file_failures_are_logged_without_exposing_native_detail', 'desktop_file_failures_log_stable_label_only', + 'desktop_file_import_validation_errors_keep_stable_invalid_request', + 'desktop_file_import_native_read_errors_use_stable_host_error', '"file export cancelled"', '"file import cancelled"', '"file export unavailable"', '"file import unavailable"', 'BASE64_STANDARD.decode', + 'fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)', + 'fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)', + 'fs::read_to_string(&path).map_err(|_error| ImportFilePayloadError::NativeRead)', 'blocking_pick_file', 'import_text_file_payload', 'import_image_file_payload', @@ -2750,6 +2763,10 @@ const requiredRustHostSnippets = [ for (const blockedDesktopFileLogSnippet of [ 'desktop file export failed for {label}: {error}', 'desktop file import failed for {label}: {error}', + 'Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error)', + 'fs::metadata(&path).map_err(|error| error.to_string())', + 'fs::read(&path).map_err(|error| error.to_string())', + 'fs::read_to_string(&path).map_err(|error| error.to_string())', 'log_desktop_file_export_failure("path.convert", &error.to_string())', 'log_desktop_file_import_failure("path.convert", &error.to_string())', 'log_desktop_file_export_failure("write.text", &error.to_string())', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs b/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs index 64a324aaf..d442898fc 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs @@ -14,6 +14,21 @@ const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 1024 * 1024; const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ImportFilePayloadError { + InvalidRequest(&'static str), + NativeRead, +} + +impl ImportFilePayloadError { + pub(crate) fn invalid_message(&self) -> Option<&'static str> { + match self { + Self::InvalidRequest(message) => Some(message), + Self::NativeRead => None, + } + } +} + pub(crate) fn normalize_export_file_name(raw_file_name: &str) -> String { let mut file_name = String::new(); let mut last_was_space = false; @@ -140,23 +155,30 @@ fn import_document_mime_type(path: &Path) -> Option<&'static str> { } } -pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { +pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { if !path.is_file() { - return Err("text file is required".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "text file is required", + )); } - let mime_type = - import_text_mime_type(&path).ok_or_else(|| "text MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let mime_type = import_text_mime_type(&path).ok_or(ImportFilePayloadError::InvalidRequest( + "text MIME must be allowed", + ))?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = metadata.len(); if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { - return Err("text exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "text exceeds import size limit", + )); } - let content = fs::read_to_string(&path).map_err(|error| error.to_string())?; + let content = fs::read_to_string(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = content.len() as u64; if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { - return Err("text exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "text exceeds import size limit", + )); } let file_name = path .file_name() @@ -173,23 +195,30 @@ pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { })) } -pub(crate) fn import_document_file_payload(path: PathBuf) -> Result { +pub(crate) fn import_document_file_payload(path: PathBuf) -> Result { if !path.is_file() { - return Err("document file is required".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "document file is required", + )); } - let mime_type = import_document_mime_type(&path) - .ok_or_else(|| "document MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let mime_type = import_document_mime_type(&path).ok_or( + ImportFilePayloadError::InvalidRequest("document MIME must be allowed"), + )?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = metadata.len(); if byte_count == 0 || byte_count > IMPORT_DOCUMENT_MAX_BYTES { - return Err("document exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "document exceeds import size limit", + )); } - let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let bytes = fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = bytes.len() as u64; if byte_count == 0 || byte_count > IMPORT_DOCUMENT_MAX_BYTES { - return Err("document exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "document exceeds import size limit", + )); } let file_name = path .file_name() @@ -302,19 +331,19 @@ fn detect_audio_mime_type(bytes: &[u8]) -> Option<&'static str> { None } -fn ensure_image_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { +fn ensure_image_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), &'static str> { if detect_image_mime_type(bytes) == Some(mime_type) { Ok(()) } else { - Err("image bytes do not match MIME".to_string()) + Err("image bytes do not match MIME") } } -fn ensure_audio_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { +fn ensure_audio_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), &'static str> { if detect_audio_mime_type(bytes) == Some(mime_type) { Ok(()) } else { - Err("audio bytes do not match MIME".to_string()) + Err("audio bytes do not match MIME") } } @@ -486,25 +515,33 @@ pub(crate) fn import_image_file_payload( path: PathBuf, action: &'static str, position: Option<(i32, i32)>, -) -> Result { +) -> Result { if !path.is_file() { - return Err("image file is required".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "image file is required", + )); } - let mime_type = - import_image_mime_type(&path).ok_or_else(|| "image MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let mime_type = import_image_mime_type(&path).ok_or(ImportFilePayloadError::InvalidRequest( + "image MIME must be allowed", + ))?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = metadata.len(); if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { - return Err("image exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "image exceeds import size limit", + )); } - let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let bytes = fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = bytes.len() as u64; if byte_count == 0 || byte_count > IMPORT_IMAGE_MAX_BYTES { - return Err("image exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "image exceeds import size limit", + )); } - ensure_image_bytes_match_mime_type(&bytes, mime_type)?; + ensure_image_bytes_match_mime_type(&bytes, mime_type) + .map_err(ImportFilePayloadError::InvalidRequest)?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -528,25 +565,33 @@ pub(crate) fn import_image_file_payload( Ok(payload) } -pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result { +pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result { if !path.is_file() { - return Err("audio file is required".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "audio file is required", + )); } - let mime_type = - import_audio_mime_type(&path).ok_or_else(|| "audio MIME must be allowed".to_string())?; - let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let mime_type = import_audio_mime_type(&path).ok_or(ImportFilePayloadError::InvalidRequest( + "audio MIME must be allowed", + ))?; + let metadata = fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = metadata.len(); if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { - return Err("audio exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "audio exceeds import size limit", + )); } - let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let bytes = fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)?; let byte_count = bytes.len() as u64; if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { - return Err("audio exceeds import size limit".to_string()); + return Err(ImportFilePayloadError::InvalidRequest( + "audio exceeds import size limit", + )); } - ensure_audio_bytes_match_mime_type(&bytes, mime_type)?; + ensure_audio_bytes_match_mime_type(&bytes, mime_type) + .map_err(ImportFilePayloadError::InvalidRequest)?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -716,7 +761,7 @@ mod tests { fs::write(&image_path, "text").expect("write image-like text"); assert_eq!( import_text_file_payload(image_path.clone()).unwrap_err(), - "text MIME must be allowed" + ImportFilePayloadError::InvalidRequest("text MIME must be allowed") ); fs::remove_file(image_path).expect("remove image-like text"); @@ -728,7 +773,7 @@ mod tests { .expect("write large text"); assert_eq!( import_text_file_payload(large_path.clone()).unwrap_err(), - "text exceeds import size limit" + ImportFilePayloadError::InvalidRequest("text exceeds import size limit") ); fs::remove_file(large_path).expect("remove large text"); } @@ -788,7 +833,7 @@ mod tests { fs::write(&image_path, b"text").expect("write image-like document"); assert_eq!( import_document_file_payload(image_path.clone()).unwrap_err(), - "document MIME must be allowed" + ImportFilePayloadError::InvalidRequest("document MIME must be allowed") ); fs::remove_file(image_path).expect("remove image-like document"); @@ -803,7 +848,7 @@ mod tests { .expect("write large document"); assert_eq!( import_document_file_payload(large_path.clone()).unwrap_err(), - "document exceeds import size limit" + ImportFilePayloadError::InvalidRequest("document exceeds import size limit") ); fs::remove_file(large_path).expect("remove large document"); } @@ -859,7 +904,7 @@ mod tests { fs::write(&text_path, b"text").expect("write text file"); assert_eq!( import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(), - "image MIME must be allowed" + ImportFilePayloadError::InvalidRequest("image MIME must be allowed") ); fs::remove_file(text_path).expect("remove text file"); @@ -870,7 +915,7 @@ mod tests { 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" + ImportFilePayloadError::InvalidRequest("image bytes do not match MIME") ); fs::remove_file(disguised_path).expect("remove disguised image"); @@ -885,7 +930,7 @@ mod tests { .expect("write large image"); assert_eq!( import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(), - "image exceeds import size limit" + ImportFilePayloadError::InvalidRequest("image exceeds import size limit") ); fs::remove_file(large_path).expect("remove large image"); } @@ -924,7 +969,7 @@ mod tests { fs::write(&text_path, b"audio").expect("write text file"); assert_eq!( import_audio_file_payload(text_path.clone()).unwrap_err(), - "audio MIME must be allowed" + ImportFilePayloadError::InvalidRequest("audio MIME must be allowed") ); fs::remove_file(text_path).expect("remove text file"); @@ -935,7 +980,7 @@ mod tests { 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" + ImportFilePayloadError::InvalidRequest("audio bytes do not match MIME") ); fs::remove_file(disguised_path).expect("remove disguised audio"); @@ -950,7 +995,7 @@ mod tests { .expect("write large audio"); assert_eq!( import_audio_file_payload(large_path.clone()).unwrap_err(), - "audio exceeds import size limit" + ImportFilePayloadError::InvalidRequest("audio exceeds import size limit") ); fs::remove_file(large_path).expect("remove large audio"); } 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 7cb256e5e..7fb34918c 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -1,7 +1,7 @@ use crate::host_bridge::file_payloads::{ export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, import_document_file_payload, import_image_file_payload, import_text_file_payload, - write_export_bytes_file, write_export_text_file, + write_export_bytes_file, write_export_text_file, ImportFilePayloadError, }; use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; use serde_json::json; @@ -16,19 +16,11 @@ fn file_import_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResp } fn file_export_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { - failed( - request.id.clone(), - "host_error", - "file export unavailable", - ) + failed(request.id.clone(), "host_error", "file export unavailable") } fn file_import_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { - failed( - request.id.clone(), - "host_error", - "file import unavailable", - ) + failed(request.id.clone(), "host_error", "file import unavailable") } fn log_desktop_file_export_failure(label: &str) -> bool { @@ -41,6 +33,20 @@ fn log_desktop_file_import_failure(label: &str) -> bool { false } +fn file_import_payload_error_response( + request: &HostBridgeRequest, + label: &str, + error: ImportFilePayloadError, +) -> HostBridgeResponse { + match error.invalid_message() { + Some(message) => failed(request.id.clone(), "invalid_request", message), + None => { + log_desktop_file_import_failure(label); + file_import_unavailable_response(request) + } + } +} + pub(crate) async fn export_desktop_host_bridge_text_file( app: &tauri::AppHandle, request: &HostBridgeRequest, @@ -112,7 +118,7 @@ pub(crate) async fn import_desktop_host_bridge_text_file( tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), - Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Ok(Err(error)) => file_import_payload_error_response(request, "read.text", error), Err(_error) => { log_desktop_file_import_failure("read.text.join"); file_import_unavailable_response(request) @@ -146,7 +152,7 @@ pub(crate) async fn import_desktop_host_bridge_document_file( tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), - Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Ok(Err(error)) => file_import_payload_error_response(request, "read.document", error), Err(_error) => { log_desktop_file_import_failure("read.document.join"); file_import_unavailable_response(request) @@ -226,7 +232,7 @@ pub(crate) async fn import_desktop_host_bridge_image_file( .await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), - Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Ok(Err(error)) => file_import_payload_error_response(request, "read.image", error), Err(_error) => { log_desktop_file_import_failure("read.image.join"); file_import_unavailable_response(request) @@ -257,7 +263,7 @@ pub(crate) async fn import_desktop_host_bridge_audio_file( tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id.clone(), payload), - Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Ok(Err(error)) => file_import_payload_error_response(request, "read.audio", error), Err(_error) => { log_desktop_file_import_failure("read.audio.join"); file_import_unavailable_response(request) @@ -364,4 +370,36 @@ mod tests { assert!(!log_desktop_file_export_failure("write.image.join")); assert!(!log_desktop_file_import_failure("read.audio.join")); } + + #[test] + fn desktop_file_import_validation_errors_keep_stable_invalid_request() { + let request = request("file.importText"); + + let error = file_import_payload_error_response( + &request, + "read.text", + ImportFilePayloadError::InvalidRequest("text MIME must be allowed"), + ) + .error + .expect("error"); + + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "text MIME must be allowed"); + } + + #[test] + fn desktop_file_import_native_read_errors_use_stable_host_error() { + let request = request("file.importText"); + + let error = file_import_payload_error_response( + &request, + "read.text", + ImportFilePayloadError::NativeRead, + ) + .error + .expect("error"); + + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "file import unavailable"); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index df97f00bf..919daca6a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3166,6 +3166,7 @@ - 背景:Tauri 桌面壳已声明文本 / 文档 / 图片 / 音频导入导出;这些能力会打开系统文件对话框、转换系统路径并在后台线程读写文件。如果系统路径转换、后台读写或任务 join 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统对话框路径异常、文件系统失败或后台任务失败。 - 决策:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 必须在导出路径转换、导出写入、导入路径转换和导入后台读取 join 失败时记录 `desktop file export failed for ...` 或 `desktop file import failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义,不透传本地路径、系统错误或线程细节;用户取消系统文件对话框仍返回 `cancelled`,不记录为异常。 - 2026-06-21 调整:桌面文件导入导出失败日志只记录 `desktop file export failed for ` / `desktop file import failed for ` 固定标签,不输出本地路径转换错误、文件读写错误、后台任务 join 错误或其它系统细节;配置检查拒绝 `files.rs` 重新拼接 `: {error}` 或把路径 / 读写 / join 错误传给日志函数。 +- 2026-06-21 调整:桌面文件导入的 MIME、类型和大小校验错误继续以稳定 `invalid_request` 返回 H5;`fs::metadata`、`fs::read`、`fs::read_to_string` 等原生读取失败统一折叠为 `host_error` / `file import unavailable`,只记录 `read.text`、`read.document`、`read.image`、`read.audio` 固定阶段标签,不把系统 IO 错误字符串作为 HostBridge 错误消息或 stderr 明细输出。 - 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::files`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 ## 2026-06-20 桌面壳窗口状态小能力系统异常必须可观测