Files
Genarrative/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs
T
kdletters 0ecafa128c 收口桌面文件导入错误边界
桌面文件导入原生读取失败返回稳定 host_error

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

补充桌面壳门禁和共享决策记录
2026-06-21 17:11:11 +08:00

1173 lines
40 KiB
Rust

use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024;
pub(crate) const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024;
const IMPORT_DOCUMENT_MAX_BYTES: u64 = 5 * 1024 * 1024;
const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024;
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;
for character in raw_file_name
.trim()
.chars()
.take(EXPORT_FILE_NAME_MAX_LENGTH)
{
if character.is_control()
|| matches!(
character,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
)
{
file_name.push('-');
last_was_space = false;
continue;
}
if character.is_whitespace() {
if !last_was_space {
file_name.push(' ');
last_was_space = true;
}
continue;
}
file_name.push(character);
last_was_space = false;
}
let file_name = file_name
.trim()
.trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace())
.trim();
if file_name.is_empty() {
EXPORT_FILE_NAME_FALLBACK.to_string()
} else {
file_name.to_string()
}
}
pub(crate) fn export_text_payload(
request: &HostBridgeRequest,
) -> Result<(String, String), HostBridgeResponse> {
let payload = request.payload.as_ref().ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"fileName and content are required",
)
})?;
let file_name = payload
.get("fileName")
.and_then(Value::as_str)
.map(normalize_export_file_name)
.unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string());
let content = payload
.get("content")
.and_then(Value::as_str)
.ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?;
if normalize_export_text_mime_type(payload.get("mimeType").and_then(Value::as_str)).is_none() {
return Err(failed(
request.id.clone(),
"invalid_request",
"mimeType must be an allowed text type",
));
}
if content.len() > EXPORT_TEXT_MAX_BYTES {
return Err(failed(
request.id.clone(),
"invalid_request",
"content exceeds file export size limit",
));
}
Ok((file_name, content.to_string()))
}
pub(crate) fn write_export_text_file(path: PathBuf, content: String) -> Result<usize, String> {
fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?;
Ok(content.len())
}
fn import_text_mime_type(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("txt") => Some("text/plain"),
Some("md") | Some("markdown") => Some("text/markdown"),
Some("csv") => Some("text/csv"),
Some("json") => Some("application/json"),
_ => None,
}
}
fn normalize_export_text_mime_type(value: Option<&str>) -> Option<&'static str> {
match value.map(|mime_type| mime_type.to_ascii_lowercase()) {
None => Some("text/plain"),
Some(mime_type) if mime_type == "text/plain" => Some("text/plain"),
Some(mime_type) if mime_type == "text/markdown" => Some("text/markdown"),
Some(mime_type) if mime_type == "text/csv" => Some("text/csv"),
Some(mime_type) if mime_type == "application/json" => Some("application/json"),
_ => None,
}
}
fn import_document_mime_type(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("docx") => {
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
}
_ => import_text_mime_type(path),
}
}
pub(crate) fn import_text_file_payload(path: PathBuf) -> Result<Value, ImportFilePayloadError> {
if !path.is_file() {
return Err(ImportFilePayloadError::InvalidRequest(
"text file is required",
));
}
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(ImportFilePayloadError::InvalidRequest(
"text exceeds import size limit",
));
}
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(ImportFilePayloadError::InvalidRequest(
"text exceeds import size limit",
));
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.map(normalize_export_file_name)
.unwrap_or_else(|| "genarrative-import.txt".to_string());
Ok(json!({
"action": "selected",
"fileName": file_name,
"content": content,
"mimeType": mime_type,
"bytes": byte_count,
}))
}
pub(crate) fn import_document_file_payload(path: PathBuf) -> Result<Value, ImportFilePayloadError> {
if !path.is_file() {
return Err(ImportFilePayloadError::InvalidRequest(
"document file is required",
));
}
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(ImportFilePayloadError::InvalidRequest(
"document exceeds import size limit",
));
}
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(ImportFilePayloadError::InvalidRequest(
"document exceeds import size limit",
));
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.map(normalize_export_file_name)
.unwrap_or_else(|| "genarrative-import-document.txt".to_string());
Ok(json!({
"action": "selected",
"fileName": file_name,
"base64Data": BASE64_STANDARD.encode(bytes),
"mimeType": mime_type,
"bytes": byte_count,
}))
}
fn export_image_extension(mime_type: &str) -> Option<&'static str> {
match mime_type {
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
_ => None,
}
}
pub(crate) fn import_image_mime_type(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("png") => Some("image/png"),
Some("jpg") | Some("jpeg") => Some("image/jpeg"),
Some("webp") => Some("image/webp"),
_ => None,
}
}
fn import_audio_mime_type(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("mp3") => Some("audio/mpeg"),
Some("m4a") | Some("mp4") => Some("audio/mp4"),
Some("wav") => Some("audio/wav"),
Some("ogg") => Some("audio/ogg"),
Some("webm") => Some("audio/webm"),
_ => None,
}
}
fn export_audio_extension(mime_type: &str) -> Option<&'static str> {
match mime_type {
"audio/mpeg" => Some("mp3"),
"audio/mp4" => Some("m4a"),
"audio/wav" => Some("wav"),
"audio/ogg" => Some("ogg"),
"audio/webm" => Some("webm"),
_ => None,
}
}
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<(), &'static str> {
if detect_image_mime_type(bytes) == Some(mime_type) {
Ok(())
} else {
Err("image bytes do not match MIME")
}
}
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")
}
}
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");
if !file_name
.to_ascii_lowercase()
.ends_with(&format!(".{}", extension))
{
file_name.push('.');
file_name.push_str(extension);
}
file_name
}
fn normalize_export_audio_file_name(raw_file_name: &str, mime_type: &str) -> String {
let mut file_name = normalize_export_file_name(raw_file_name);
let extension = export_audio_extension(mime_type).unwrap_or("webm");
if !file_name
.to_ascii_lowercase()
.ends_with(&format!(".{}", extension))
{
file_name.push('.');
file_name.push_str(extension);
}
file_name
}
pub(crate) fn export_image_payload(
request: &HostBridgeRequest,
) -> Result<(String, Vec<u8>), HostBridgeResponse> {
let payload = request.payload.as_ref().ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"fileName, mimeType and base64Data are required",
)
})?;
let mime_type = payload
.get("mimeType")
.and_then(Value::as_str)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"mimeType is required",
)
})?;
if export_image_extension(mime_type).is_none() {
return Err(failed(
request.id.clone(),
"invalid_request",
"mimeType must be an allowed image type",
));
}
let base64_data = payload
.get("base64Data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"base64Data is required",
)
})?;
let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| {
failed(
request.id.clone(),
"invalid_request",
"base64Data is invalid",
)
})?;
if bytes.is_empty() || bytes.len() > EXPORT_IMAGE_MAX_BYTES {
return Err(failed(
request.id.clone(),
"invalid_request",
"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")
.and_then(Value::as_str)
.map(|file_name| normalize_export_image_file_name(file_name, mime_type))
.unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type));
Ok((file_name, bytes))
}
pub(crate) fn export_audio_payload(
request: &HostBridgeRequest,
) -> Result<(String, Vec<u8>), HostBridgeResponse> {
let payload = request.payload.as_ref().ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"fileName, mimeType and base64Data are required",
)
})?;
let mime_type = payload
.get("mimeType")
.and_then(Value::as_str)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"mimeType is required",
)
})?;
if export_audio_extension(mime_type).is_none() {
return Err(failed(
request.id.clone(),
"invalid_request",
"mimeType must be an allowed audio type",
));
}
let base64_data = payload
.get("base64Data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"base64Data is required",
)
})?;
let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| {
failed(
request.id.clone(),
"invalid_request",
"base64Data is invalid",
)
})?;
if bytes.is_empty() || bytes.len() > EXPORT_AUDIO_MAX_BYTES {
return Err(failed(
request.id.clone(),
"invalid_request",
"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")
.and_then(Value::as_str)
.map(|file_name| normalize_export_audio_file_name(file_name, mime_type))
.unwrap_or_else(|| normalize_export_audio_file_name("genarrative-audio", mime_type));
Ok((file_name, bytes))
}
pub(crate) fn write_export_bytes_file(path: PathBuf, bytes: Vec<u8>) -> Result<usize, String> {
let byte_count = bytes.len();
fs::write(path, bytes).map_err(|error| error.to_string())?;
Ok(byte_count)
}
pub(crate) fn import_image_file_payload(
path: PathBuf,
action: &'static str,
position: Option<(i32, i32)>,
) -> Result<Value, ImportFilePayloadError> {
if !path.is_file() {
return Err(ImportFilePayloadError::InvalidRequest(
"image file is required",
));
}
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(ImportFilePayloadError::InvalidRequest(
"image exceeds import size limit",
));
}
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(ImportFilePayloadError::InvalidRequest(
"image exceeds import size limit",
));
}
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())
.map(normalize_export_file_name)
.unwrap_or_else(|| "genarrative-import.png".to_string());
let mut payload = json!({
"action": action,
"fileName": file_name,
"base64Data": BASE64_STANDARD.encode(bytes),
"mimeType": mime_type,
"bytes": byte_count,
});
if let Some((x, y)) = position {
payload["position"] = json!({
"x": x,
"y": y,
});
}
Ok(payload)
}
pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result<Value, ImportFilePayloadError> {
if !path.is_file() {
return Err(ImportFilePayloadError::InvalidRequest(
"audio file is required",
));
}
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(ImportFilePayloadError::InvalidRequest(
"audio exceeds import size limit",
));
}
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(ImportFilePayloadError::InvalidRequest(
"audio exceeds import size limit",
));
}
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())
.map(normalize_export_file_name)
.unwrap_or_else(|| "genarrative-import-audio.webm".to_string());
Ok(json!({
"action": "selected",
"fileName": file_name,
"base64Data": BASE64_STANDARD.encode(bytes),
"mimeType": mime_type,
"bytes": byte_count,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
fn png_bytes() -> Vec<u8> {
vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
}
fn jpeg_bytes() -> Vec<u8> {
vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, b'J', b'F', b'I', b'F']
}
fn webp_bytes() -> Vec<u8> {
b"RIFF\x04\x00\x00\x00WEBP".to_vec()
}
fn mp3_bytes() -> Vec<u8> {
b"ID3\x04\x00\x00\x00\x00\x00\x10".to_vec()
}
fn mp4_audio_bytes() -> Vec<u8> {
b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00".to_vec()
}
fn wav_bytes() -> Vec<u8> {
b"RIFF\x04\x00\x00\x00WAVE".to_vec()
}
fn ogg_bytes() -> Vec<u8> {
b"OggS\x00\x02audio".to_vec()
}
fn webm_bytes() -> Vec<u8> {
vec![0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]
}
#[test]
fn export_file_name_normalization_rejects_path_like_characters() {
assert_eq!(
normalize_export_file_name(" 作品:记录?.txt "),
"作品-记录-.txt"
);
assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt");
assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK);
let long_file_name = "甲".repeat(140);
assert_eq!(
normalize_export_file_name(&long_file_name).chars().count(),
EXPORT_FILE_NAME_MAX_LENGTH
);
}
#[test]
fn export_text_payload_requires_text_content() {
let mut invalid = request("file.exportText");
invalid.payload = Some(json!({
"fileName": "作品记录.txt",
"content": 123
}));
let response = export_text_payload(&invalid).expect_err("invalid content");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "content is required");
}
#[test]
fn export_text_payload_rejects_oversized_content() {
let mut invalid = request("file.exportText");
invalid.payload = Some(json!({
"fileName": "作品记录.txt",
"content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1)
}));
let response = export_text_payload(&invalid).expect_err("oversized content");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "content exceeds file export size limit");
}
#[test]
fn export_text_payload_rejects_non_text_mime_type() {
let mut invalid = request("file.exportText");
invalid.payload = Some(json!({
"fileName": "作品记录.txt",
"content": "暖灯猫街",
"mimeType": "image/png"
}));
let response = export_text_payload(&invalid).expect_err("invalid MIME");
assert!(!response.ok);
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "mimeType must be an allowed text type");
}
#[test]
fn write_export_text_file_persists_utf8_content() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-export-{}.txt",
std::process::id()
));
let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string())
.expect("write export file");
assert_eq!(bytes, "暖灯猫街".len());
assert_eq!(
fs::read_to_string(&path).expect("read export file"),
"暖灯猫街"
);
fs::remove_file(path).expect("remove export file");
}
#[test]
fn import_text_file_payload_reads_allowed_text_without_exposing_path() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-text-{}.md",
std::process::id()
));
fs::write(&path, "暖灯猫街").expect("write import text");
let payload = import_text_file_payload(path.clone()).expect("payload");
assert_eq!(payload["action"], "selected");
assert_eq!(
payload["fileName"],
path.file_name().unwrap().to_str().unwrap()
);
assert_eq!(payload["content"], "暖灯猫街");
assert_eq!(payload["mimeType"], "text/markdown");
assert_eq!(payload["bytes"], "暖灯猫街".len() as u64);
assert!(!payload
.to_string()
.contains(path.to_string_lossy().as_ref()));
fs::remove_file(path).expect("remove import text");
}
#[test]
fn import_text_file_payload_rejects_invalid_or_oversized_text() {
let image_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-text-{}.png",
std::process::id()
));
fs::write(&image_path, "text").expect("write image-like text");
assert_eq!(
import_text_file_payload(image_path.clone()).unwrap_err(),
ImportFilePayloadError::InvalidRequest("text MIME must be allowed")
);
fs::remove_file(image_path).expect("remove image-like text");
let large_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-text-large-{}.txt",
std::process::id()
));
fs::write(&large_path, "a".repeat(IMPORT_TEXT_MAX_BYTES as usize + 1))
.expect("write large text");
assert_eq!(
import_text_file_payload(large_path.clone()).unwrap_err(),
ImportFilePayloadError::InvalidRequest("text exceeds import size limit")
);
fs::remove_file(large_path).expect("remove large text");
}
#[test]
fn import_document_file_payload_reads_docx_without_exposing_path() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-{}.docx",
std::process::id()
));
let bytes = b"PK\x03\x04docx".to_vec();
fs::write(&path, &bytes).expect("write import document");
let payload = import_document_file_payload(path.clone()).expect("payload");
assert_eq!(payload["action"], "selected");
assert_eq!(
payload["fileName"],
path.file_name().unwrap().to_str().unwrap()
);
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(&bytes));
assert_eq!(
payload["mimeType"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
assert_eq!(payload["bytes"], bytes.len() as u64);
assert!(!payload
.to_string()
.contains(path.to_string_lossy().as_ref()));
fs::remove_file(path).expect("remove import document");
}
#[test]
fn import_document_file_payload_reuses_text_document_mime_types() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-{}.md",
std::process::id()
));
fs::write(&path, "暖灯猫街").expect("write import document");
let payload = import_document_file_payload(path.clone()).expect("payload");
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode("暖灯猫街"));
assert_eq!(payload["mimeType"], "text/markdown");
assert_eq!(payload["bytes"], "暖灯猫街".len() as u64);
fs::remove_file(path).expect("remove import document");
}
#[test]
fn import_document_file_payload_rejects_invalid_or_oversized_documents() {
let image_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-{}.png",
std::process::id()
));
fs::write(&image_path, b"text").expect("write image-like document");
assert_eq!(
import_document_file_payload(image_path.clone()).unwrap_err(),
ImportFilePayloadError::InvalidRequest("document MIME must be allowed")
);
fs::remove_file(image_path).expect("remove image-like document");
let large_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-document-large-{}.docx",
std::process::id()
));
fs::write(
&large_path,
vec![b'a'; IMPORT_DOCUMENT_MAX_BYTES as usize + 1],
)
.expect("write large document");
assert_eq!(
import_document_file_payload(large_path.clone()).unwrap_err(),
ImportFilePayloadError::InvalidRequest("document exceeds import size limit")
);
fs::remove_file(large_path).expect("remove large document");
}
#[test]
fn export_image_payload_decodes_allowed_image_base64() {
let mut valid = request("file.exportImage");
valid.payload = Some(json!({
"fileName": "分享:卡?.png",
"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, png_bytes());
}
#[test]
fn import_image_file_payload_reads_allowed_image_without_exposing_path() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-{}.png",
std::process::id()
));
fs::write(&path, png_bytes()).expect("write import image");
let payload =
import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload");
assert_eq!(payload["action"], "selected");
assert_eq!(
payload["fileName"],
path.file_name().unwrap().to_str().unwrap()
);
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(png_bytes()));
assert_eq!(payload["mimeType"], "image/png");
assert_eq!(payload["bytes"], png_bytes().len() as u64);
assert_eq!(payload["position"], json!({ "x": 12, "y": 24 }));
assert!(!payload
.to_string()
.contains(path.to_string_lossy().as_ref()));
fs::remove_file(path).expect("remove import image");
}
#[test]
fn import_image_file_payload_rejects_invalid_or_oversized_images() {
let text_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-{}.txt",
std::process::id()
));
fs::write(&text_path, b"text").expect("write text file");
assert_eq!(
import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(),
ImportFilePayloadError::InvalidRequest("image MIME must be allowed")
);
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(),
ImportFilePayloadError::InvalidRequest("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()
));
fs::write(
&large_path,
vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize],
)
.expect("write large image");
assert_eq!(
import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(),
ImportFilePayloadError::InvalidRequest("image exceeds import size limit")
);
fs::remove_file(large_path).expect("remove large image");
}
#[test]
fn import_audio_file_payload_reads_allowed_audio_without_exposing_path() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-audio-{}.webm",
std::process::id()
));
fs::write(&path, webm_bytes()).expect("write import audio");
let payload = import_audio_file_payload(path.clone()).expect("payload");
assert_eq!(payload["action"], "selected");
assert_eq!(
payload["fileName"],
path.file_name().unwrap().to_str().unwrap()
);
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(webm_bytes()));
assert_eq!(payload["mimeType"], "audio/webm");
assert_eq!(payload["bytes"], webm_bytes().len() as u64);
assert!(!payload
.to_string()
.contains(path.to_string_lossy().as_ref()));
fs::remove_file(path).expect("remove import audio");
}
#[test]
fn import_audio_file_payload_rejects_invalid_or_oversized_audio() {
let text_path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-import-audio-{}.txt",
std::process::id()
));
fs::write(&text_path, b"audio").expect("write text file");
assert_eq!(
import_audio_file_payload(text_path.clone()).unwrap_err(),
ImportFilePayloadError::InvalidRequest("audio MIME must be allowed")
);
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(),
ImportFilePayloadError::InvalidRequest("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()
));
fs::write(
&large_path,
vec![1u8; (IMPORT_AUDIO_MAX_BYTES + 1) as usize],
)
.expect("write large audio");
assert_eq!(
import_audio_file_payload(large_path.clone()).unwrap_err(),
ImportFilePayloadError::InvalidRequest("audio exceeds import size limit")
);
fs::remove_file(large_path).expect("remove large audio");
}
#[test]
fn export_audio_payload_decodes_allowed_audio_base64() {
let mut valid = request("file.exportAudio");
valid.payload = Some(json!({
"fileName": "敲击:音效?.wav",
"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, wav_bytes());
let mut missing_extension = request("file.exportAudio");
missing_extension.payload = Some(json!({
"fileName": "敲击音效",
"base64Data": BASE64_STANDARD.encode(webm_bytes()),
"mimeType": "audio/webm"
}));
let (file_name, _bytes) = export_audio_payload(&missing_extension).expect("audio payload");
assert_eq!(file_name, "敲击音效.webm");
}
#[test]
fn export_audio_payload_rejects_invalid_or_oversized_audio() {
let mut invalid_mime = request("file.exportAudio");
invalid_mime.payload = Some(json!({
"fileName": "hit.txt",
"base64Data": BASE64_STANDARD.encode(wav_bytes()),
"mimeType": "text/plain"
}));
let response = export_audio_payload(&invalid_mime).expect_err("invalid mime");
assert!(!response.ok);
assert_eq!(response.error.expect("error").code, "invalid_request");
let mut empty = request("file.exportAudio");
empty.payload = Some(json!({
"fileName": "hit.wav",
"base64Data": "",
"mimeType": "audio/wav"
}));
let response = export_audio_payload(&empty).expect_err("empty audio");
assert!(!response.ok);
assert_eq!(response.error.expect("error").code, "invalid_request");
let mut oversized = request("file.exportAudio");
oversized.payload = Some(json!({
"fileName": "hit.webm",
"base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_AUDIO_MAX_BYTES + 1]),
"mimeType": "audio/webm"
}));
let response = export_audio_payload(&oversized).expect_err("oversized audio");
assert!(!response.ok);
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]
fn export_image_payload_rejects_invalid_mime_and_base64() {
let mut invalid_mime = request("file.exportImage");
invalid_mime.payload = Some(json!({
"fileName": "分享卡.txt",
"base64Data": "c2hhcmUtY2FyZA==",
"mimeType": "text/plain"
}));
let response = export_image_payload(&invalid_mime).expect_err("invalid mime");
assert_eq!(response.error.expect("error").code, "invalid_request");
let mut invalid_base64 = request("file.exportImage");
invalid_base64.payload = Some(json!({
"fileName": "分享卡.png",
"base64Data": "not base64!",
"mimeType": "image/png"
}));
let response = export_image_payload(&invalid_base64).expect_err("invalid base64");
assert_eq!(
response.error.expect("error").message,
"base64Data is invalid"
);
let mut empty = request("file.exportImage");
empty.payload = Some(json!({
"fileName": "分享卡.png",
"base64Data": "",
"mimeType": "image/png"
}));
let response = export_image_payload(&empty).expect_err("empty image");
assert_eq!(response.error.expect("error").code, "invalid_request");
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]
fn export_image_payload_rejects_oversized_image() {
let mut invalid = request("file.exportImage");
invalid.payload = Some(json!({
"fileName": "分享卡.png",
"base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]),
"mimeType": "image/png"
}));
let response = export_image_payload(&invalid).expect_err("oversized image");
assert_eq!(
response.error.expect("error").message,
"image exceeds file export size limit"
);
}
#[test]
fn write_export_bytes_file_persists_binary_content() {
let path = std::env::temp_dir().join(format!(
"genarrative-host-bridge-share-card-{}.png",
std::process::id()
));
let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G'])
.expect("write image file");
assert_eq!(bytes, 4);
assert_eq!(
fs::read(&path).expect("read image file"),
vec![0x89, b'P', b'N', b'G']
);
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);
}
}