0ecafa128c
桌面文件导入原生读取失败返回稳定 host_error 文件导入校验错误保留稳定 invalid_request 语义 补充桌面壳门禁和共享决策记录
1173 lines
40 KiB
Rust
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);
|
|
}
|
|
}
|