use crate::storage;
use serde::Serialize;
use std::{fs, path::Path};
use tauri::http::{Request, Response, StatusCode};
const MAX_BYTES: u64 = 50 * 1024 * 1024;
pub fn image_type(bytes: &[u8]) -> Option<(&'static str, &'static str)> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some(("image/png", "png"))
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
Some(("image/jpeg", "jpg"))
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some(("image/gif", "gif"))
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
Some(("image/webp", "webp"))
} else {
None
}
}
#[derive(Serialize)]
pub struct ImportedAsset {
path: String,
name: String,
image: bool,
}
pub fn import(
root: &Path,
name: &str,
bytes: &[u8],
require_image: bool,
identity: Option<(&str, &Path)>,
) -> Result<ImportedAsset, String> {
if bytes.len() as u64 > MAX_BYTES {
return Err("附件不能超过 50 MiB".into());
}
let image = image_type(bytes);
if require_image && image.is_none() {
return Err("图片支持 PNG、JPEG、GIF 和 WebP;SVG 等格式请作为附件导入".into());
}
let basename = name.rsplit(['/', '\\']).next().unwrap_or("attachment");
let (stem, extension) = basename.rsplit_once('.').unwrap_or((basename, ""));
let stem: String = stem
.chars()
.filter(|c| !c.is_control() && !"<>:\"/\\|?*.".contains(*c))
.take(40)
.collect::<String>()
.trim()
.into();
let extension = image
.map(|(_, extension)| extension.to_string())
.unwrap_or_else(|| {
extension
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(12)
.collect()
});
let directory = storage::resolve(root, "assets", false)?;
if !directory.exists() {
fs::create_dir(&directory).map_err(|e| e.to_string())?;
}
if !directory.is_dir() {
return Err("assets 已存在且不是文件夹".into());
}
let filename = format!(
"{}-{}{}",
if stem.is_empty() { "attachment" } else { &stem },
uuid::Uuid::new_v4(),
if extension.is_empty() {
String::new()
} else {
format!(".{extension}")
}
);
let relative = format!("assets/{filename}");
let target = storage::resolve(root, &relative, false)?;
if let Some((workspace_id, cache)) = identity {
crate::identity_manifest::begin_create(root, workspace_id, cache, &relative, bytes)?;
}
if let Err(error) = storage::create_atomic(&target, bytes) {
if let Some((workspace_id, cache)) = identity {
let _ = crate::identity_manifest::recover_mutation(root, workspace_id, cache);
}
return Err(error);
}
if let Some((workspace_id, cache)) = identity {
crate::identity_manifest::recover_mutation(root, workspace_id, cache)?;
}
Ok(ImportedAsset {
path: relative,
name: basename.into(),
image: image.is_some(),
})
}
pub fn read(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
let target = storage::resolve(root, relative, true)?;
let metadata = fs::metadata(&target).map_err(|e| e.to_string())?;
if !metadata.is_file() || metadata.len() > MAX_BYTES {
return Err("附件无效或超过 50 MiB".into());
}
fs::read(target).map_err(|e| e.to_string())
}
pub fn write_spreadsheet(
root: &Path,
relative: &str,
bytes: &[u8],
expected: &str,
backups: &Path,
) -> Result<String, String> {
let target = storage::resolve(root, relative, true)?;
let extension = target
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
if !matches!(extension.as_str(), "xls" | "xlsx") || bytes.len() as u64 > MAX_BYTES {
return Err("仅支持保存 50 MiB 以内的 Excel 工作簿".into());
}
if !(extension == "xlsx" && bytes.starts_with(b"PK\x03\x04")
|| extension == "xls" && bytes.starts_with(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"))
{
return Err("工作簿内容与扩展名不符".into());
}
let original = read(root, relative)?;
if storage::hash(&original) != expected {
return Err("原文件已被外部修改,本地草稿已保留;请重新打开后处理冲突".into());
}
let permissions = fs::metadata(&target)
.map_err(|e| e.to_string())?
.permissions();
if permissions.readonly() {
return Err("工作簿为只读文件".into());
}
fs::create_dir_all(backups).map_err(|e| e.to_string())?;
let backup = backups.join(format!("{}.{}", uuid::Uuid::new_v4(), extension));
storage::create_atomic(&backup, &original)?;
let staged = storage::temporary(&target, bytes)?;
staged
.as_file()
.set_permissions(permissions)
.map_err(|e| e.to_string())?;
if storage::resolve(root, relative, true)? != target
|| storage::hash(&read(root, relative)?) != expected
{
return Err("原文件已被外部修改,本地草稿与原文件备份已保留".into());
}
staged.persist(&target).map_err(|e| e.to_string())?;
Ok(backup.to_string_lossy().into_owned())
}
pub fn respond(root: &Path, workspace_id: &str, request: &Request<Vec<u8>>) -> Response<Vec<u8>> {
let result = (|| {
if request.method() != "GET" {
return Err(StatusCode::METHOD_NOT_ALLOWED);
}
let decoded =
percent_encoding::percent_decode_str(request.uri().path().trim_start_matches('/'))
.decode_utf8()
.map_err(|_| StatusCode::BAD_REQUEST)?;
let (id, path) = decoded.split_once('/').ok_or(StatusCode::BAD_REQUEST)?;
if id != workspace_id {
return Err(StatusCode::NOT_FOUND);
}
let bytes = read(root, path).map_err(|_| StatusCode::NOT_FOUND)?;
let (mime, _) = image_type(&bytes).ok_or(StatusCode::UNSUPPORTED_MEDIA_TYPE)?;
Ok((mime, bytes))
})();
let (status, mime, bytes) = match result {
Ok((mime, bytes)) => (StatusCode::OK, mime, bytes),
Err(status) => (status, "text/plain", b"Image unavailable".to_vec()),
};
let mut response = Response::builder()
.status(status)
.header("Content-Type", mime)
.header("Cache-Control", "no-store")
.header("X-Content-Type-Options", "nosniff")
.header("Content-Security-Policy", "default-src 'none'; sandbox")
.body(bytes)
.expect("static response headers");
if let Some(origin) = request.headers().get("Origin") {
if origin
.to_str()
.ok()
.and_then(|value| tauri::Url::parse(value).ok())
.is_some_and(|url| {
matches!(url.scheme(), "http" | "https" | "tauri")
&& matches!(
url.host_str(),
Some("localhost" | "127.0.0.1" | "tauri.localhost")
)
})
{
response
.headers_mut()
.insert("Access-Control-Allow-Origin", origin.clone());
response
.headers_mut()
.insert("Vary", "Origin".parse().unwrap());
}
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spreadsheet_save_backs_up_and_refuses_external_changes() {
let temp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
let backups = tempfile::tempdir().unwrap();
let path = root.join("book.xlsx");
let original = b"PK\x03\x04original";
let updated = b"PK\x03\x04updated";
fs::write(&path, original).unwrap();
let backup = write_spreadsheet(
&root,
"book.xlsx",
updated,
&storage::hash(original),
backups.path(),
)
.unwrap();
assert_eq!(fs::read(backup).unwrap(), original);
assert_eq!(fs::read(&path).unwrap(), updated);
assert!(write_spreadsheet(
&root,
"book.xlsx",
original,
&storage::hash(original),
backups.path()
)
.is_err());
assert_eq!(fs::read(&path).unwrap(), updated);
assert!(write_spreadsheet(
&root,
"../book.xlsx",
updated,
&storage::hash(updated),
backups.path()
)
.is_err());
}
#[test]
fn imports_without_overwrite_and_serves_only_selected_workspace_images() {
let temp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
let bytes = b"\x89PNG\r\n\x1a\nfixture";
let a = import(&root, "图 片.png", bytes, true, None).unwrap();
let b = import(&root, "图 片.png", bytes, true, None).unwrap();
assert_ne!(a.path, b.path);
let uri = format!(
"tano-asset://localhost/workspace/{}",
percent_encoding::utf8_percent_encode(&a.path, percent_encoding::NON_ALPHANUMERIC)
);
let request = Request::builder().uri(uri).body(vec![]).unwrap();
let response = respond(&root, "workspace", &request);
assert_eq!(response.status(), 200);
assert_eq!(response.body(), bytes);
assert_eq!(response.headers()["content-type"], "image/png");
assert_eq!(respond(&root, "another-workspace", &request).status(), 404);
let local_request = Request::builder()
.uri(request.uri())
.header("Origin", "http://tauri.localhost")
.body(vec![])
.unwrap();
assert_eq!(
respond(&root, "workspace", &local_request).headers()["access-control-allow-origin"],
"http://tauri.localhost"
);
let external_request = Request::builder()
.uri(request.uri())
.header("Origin", "https://example.com")
.body(vec![])
.unwrap();
assert!(!respond(&root, "workspace", &external_request)
.headers()
.contains_key("access-control-allow-origin"));
}
#[test]
fn reads_binary_export_attachments_without_relaxing_image_preview_or_path_boundaries() {
let temp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
let bytes = b"%PDF-1.7\n\0\xff";
fs::write(root.join("attachment.pdf"), bytes).unwrap();
assert_eq!(read(&root, "attachment.pdf").unwrap(), bytes);
let request = Request::builder()
.uri("tano-asset://localhost/workspace/attachment.pdf")
.body(vec![])
.unwrap();
assert_eq!(respond(&root, "workspace", &request).status(), 415);
assert!(read(&root, "../attachment.pdf").is_err());
assert!(read(&root, ".tano/config.json").is_err());
}
#[test]
fn imported_attachment_identity_survives_index_loss() {
let temp = tempfile::tempdir().unwrap();
let data = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
let id = storage::workspace_identity(&root).unwrap();
let cache = data.path().join("index.sqlite");
storage::scan(&root, &id, &cache).unwrap();
let asset = import(
&root,
"image.png",
b"\x89PNG\r\n\x1a\nfixture",
true,
Some((&id, &cache)),
)
.unwrap();
let before = storage::scan(&root, &id, &cache).unwrap();
let file_id = before
.files
.iter()
.find(|file| file.path == asset.path)
.unwrap()
.id
.clone();
fs::remove_file(&cache).unwrap();
let after = storage::scan(&root, &id, &cache).unwrap();
assert_eq!(
after
.files
.iter()
.find(|file| file.path == asset.path)
.unwrap()
.id,
file_id
);
}
#[test]
fn rejects_hidden_escape_and_active_content() {
let temp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
fs::write(root.join("page.svg"), b"<svg onload='alert(1)'/>").unwrap();
assert!(import(&root, "image.svg", b"<svg/>", true, None).is_err());
for path in [
"../secret.png",
"%2e%2e/secret.png",
".tano/config.json",
"C:/secret.png",
"page.svg",
] {
let request = Request::builder()
.uri(format!("tano-asset://localhost/workspace/{path}"))
.body(vec![])
.unwrap();
assert_ne!(respond(&root, "workspace", &request).status(), 200);
}
}
#[cfg(unix)]
#[test]
fn never_serves_a_symlink_outside_workspace() {
let temp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
fs::write(
outside.path().join("private.png"),
b"\x89PNG\r\n\x1a\nprivate",
)
.unwrap();
std::os::unix::fs::symlink(
outside.path().join("private.png"),
temp.path().join("link.png"),
)
.unwrap();
assert!(read(temp.path(), "link.png").is_err());
}
}