收口桌面文件导入错误边界

桌面文件导入原生读取失败返回稳定 host_error

文件导入校验错误保留稳定 invalid_request 语义

补充桌面壳门禁和共享决策记录
This commit is contained in:
2026-06-21 17:11:11 +08:00
parent c0624e1576
commit 0ecafa128c
4 changed files with 164 additions and 63 deletions
@@ -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())',
@@ -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<Value, String> {
pub(crate) fn import_text_file_payload(path: PathBuf) -> Result<Value, ImportFilePayloadError> {
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<Value, String> {
}))
}
pub(crate) fn import_document_file_payload(path: PathBuf) -> Result<Value, String> {
pub(crate) fn import_document_file_payload(path: PathBuf) -> Result<Value, ImportFilePayloadError> {
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<Value, String> {
) -> Result<Value, ImportFilePayloadError> {
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<Value, String> {
pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result<Value, ImportFilePayloadError> {
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");
}
@@ -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");
}
}
@@ -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 <stage>` / `desktop file import failed for <stage>` 固定标签,不输出本地路径转换错误、文件读写错误、后台任务 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 桌面壳窗口状态小能力系统异常必须可观测