ffb4cb5d62
将资源卡本体化并补齐分区缩放滚动与依赖聚类 增加全进程三槽预览调度范围取消与安全分块读取 保持布局 CAS 预览缓存回收和依赖图权威合同 补齐资源管理前端 Tauri AppSurface 回归并同步文档
522 lines
18 KiB
Rust
522 lines
18 KiB
Rust
use crate::image_inspect::{
|
|
same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors,
|
|
};
|
|
use crate::project::{
|
|
normalize_relative_path, open_project_snapshot_regular_file,
|
|
reject_sensitive_project_file_read, resolve_local_project_path,
|
|
};
|
|
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
|
|
use base64::Engine as _;
|
|
use serde::Serialize;
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
|
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
|
|
const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
|
|
|
|
#[derive(Debug, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct LocalProjectTextPreview {
|
|
pub(crate) path: String,
|
|
pub(crate) media_type: String,
|
|
pub(crate) byte_len: u64,
|
|
pub(crate) content: String,
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct LocalProjectMediaPreview {
|
|
pub(crate) path: String,
|
|
pub(crate) media_type: String,
|
|
pub(crate) byte_len: u64,
|
|
pub(crate) data_url: String,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ProjectMediaPreviewKind {
|
|
Art,
|
|
Audio,
|
|
}
|
|
|
|
pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool {
|
|
let media_type = media_type.trim().to_ascii_lowercase();
|
|
matches!(
|
|
path_extension(path).as_deref(),
|
|
Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml")
|
|
) && (media_type.is_empty()
|
|
|| media_type.starts_with("text/")
|
|
|| media_type.contains("json")
|
|
|| media_type.contains("yaml")
|
|
|| matches!(
|
|
media_type.as_str(),
|
|
"项目文档" | "application/toml" | "application/mdx"
|
|
))
|
|
}
|
|
|
|
pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool {
|
|
let media_type = media_type.trim().to_ascii_lowercase();
|
|
matches!(
|
|
path_extension(path).as_deref(),
|
|
Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov")
|
|
) || media_type.starts_with("video/")
|
|
|| media_type == "image/svg+xml"
|
|
}
|
|
|
|
pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool {
|
|
let media_type = media_type.trim().to_ascii_lowercase();
|
|
matches!(
|
|
path_extension(path).as_deref(),
|
|
Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus")
|
|
) || media_type.starts_with("audio/")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn load_local_project_text_preview(
|
|
root: &Path,
|
|
relative_path: &str,
|
|
) -> Result<LocalProjectTextPreview, String> {
|
|
load_local_project_text_preview_with_cancellation(
|
|
root,
|
|
relative_path,
|
|
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn load_local_project_text_preview_with_cancellation(
|
|
root: &Path,
|
|
relative_path: &str,
|
|
cancellation: &ProjectResourcePreviewScopeCancellation,
|
|
) -> Result<LocalProjectTextPreview, String> {
|
|
cancellation.check()?;
|
|
let normalized = normalize_relative_path(relative_path.trim())?;
|
|
reject_sensitive_project_file_read(&normalized)?;
|
|
let media_type = project_text_media_type(&normalized)
|
|
.ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?;
|
|
let bytes = read_stable_project_resource(
|
|
root,
|
|
&normalized,
|
|
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
|
|
"项目文档",
|
|
cancellation,
|
|
)?;
|
|
cancellation.check()?;
|
|
let content =
|
|
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
|
|
Ok(LocalProjectTextPreview {
|
|
path: normalized,
|
|
media_type: media_type.to_string(),
|
|
byte_len: content.len() as u64,
|
|
content,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn load_local_project_media_preview(
|
|
root: &Path,
|
|
relative_path: &str,
|
|
kind: ProjectMediaPreviewKind,
|
|
) -> Result<LocalProjectMediaPreview, String> {
|
|
load_local_project_media_preview_with_cancellation(
|
|
root,
|
|
relative_path,
|
|
kind,
|
|
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn load_local_project_media_preview_with_cancellation(
|
|
root: &Path,
|
|
relative_path: &str,
|
|
kind: ProjectMediaPreviewKind,
|
|
cancellation: &ProjectResourcePreviewScopeCancellation,
|
|
) -> Result<LocalProjectMediaPreview, String> {
|
|
cancellation.check()?;
|
|
let normalized = normalize_relative_path(relative_path.trim())?;
|
|
reject_sensitive_project_file_read(&normalized)?;
|
|
let bytes = read_stable_project_resource(
|
|
root,
|
|
&normalized,
|
|
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
|
|
"项目媒体资源",
|
|
cancellation,
|
|
)?;
|
|
if bytes.is_empty() {
|
|
return Err("媒体文件为空,无法预览".to_string());
|
|
}
|
|
cancellation.check()?;
|
|
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
|
|
cancellation.check()?;
|
|
Ok(LocalProjectMediaPreview {
|
|
path: normalized,
|
|
media_type: media_type.to_string(),
|
|
byte_len: bytes.len() as u64,
|
|
data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?,
|
|
})
|
|
}
|
|
|
|
fn encode_project_resource_preview_data_url(
|
|
media_type: &str,
|
|
bytes: &[u8],
|
|
cancellation: &ProjectResourcePreviewScopeCancellation,
|
|
) -> Result<String, String> {
|
|
cancellation.check()?;
|
|
Ok(format!(
|
|
"data:{media_type};base64,{}",
|
|
base64::engine::general_purpose::STANDARD.encode(bytes)
|
|
))
|
|
}
|
|
|
|
fn read_stable_project_resource(
|
|
root: &Path,
|
|
normalized: &str,
|
|
max_bytes: u64,
|
|
label: &str,
|
|
cancellation: &ProjectResourcePreviewScopeCancellation,
|
|
) -> Result<Vec<u8>, String> {
|
|
cancellation.check()?;
|
|
let absolute = resolve_local_project_path(root, normalized)?;
|
|
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
|
|
cancellation.check()?;
|
|
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
|
|
cancellation.check()?;
|
|
if initial_metadata.len() > max_bytes {
|
|
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
|
|
}
|
|
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
|
|
let mut chunk = [0_u8; PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES];
|
|
loop {
|
|
cancellation.check()?;
|
|
let remaining = (max_bytes + 1).saturating_sub(bytes.len() as u64);
|
|
if remaining == 0 {
|
|
break;
|
|
}
|
|
let read_len = usize::try_from(remaining)
|
|
.unwrap_or(usize::MAX)
|
|
.min(chunk.len());
|
|
let count = file
|
|
.read(&mut chunk[..read_len])
|
|
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
bytes.extend_from_slice(&chunk[..count]);
|
|
}
|
|
cancellation.check()?;
|
|
if bytes.len() as u64 > max_bytes {
|
|
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
|
|
}
|
|
let final_metadata = file
|
|
.metadata()
|
|
.map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?;
|
|
if initial_metadata.len() != bytes.len() as u64
|
|
|| final_metadata.len() != bytes.len() as u64
|
|
|| !same_open_file_snapshot(&initial_metadata, &final_metadata)
|
|
{
|
|
return Err(format!("{label}读取期间发生漂移:{normalized}"));
|
|
}
|
|
cancellation.check()?;
|
|
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
|
|
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
|
|
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
|
|
}
|
|
cancellation.check()?;
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn project_text_media_type(path: &str) -> Option<&'static str> {
|
|
match path_extension(path).as_deref()? {
|
|
"md" | "markdown" | "mdx" => Some("text/markdown"),
|
|
"txt" => Some("text/plain"),
|
|
"json" => Some("application/json"),
|
|
"yaml" | "yml" => Some("application/yaml"),
|
|
"toml" => Some("application/toml"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn detect_project_media_type(
|
|
path: &str,
|
|
bytes: &[u8],
|
|
kind: ProjectMediaPreviewKind,
|
|
) -> Result<&'static str, String> {
|
|
if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") {
|
|
validate_safe_svg(bytes)?;
|
|
return Ok("image/svg+xml");
|
|
}
|
|
if kind == ProjectMediaPreviewKind::Art {
|
|
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
|
return Ok("image/gif");
|
|
}
|
|
if bytes.starts_with(b"BM") {
|
|
return Ok("image/bmp");
|
|
}
|
|
if is_avif(bytes) {
|
|
return Ok("image/avif");
|
|
}
|
|
if is_iso_base_media(bytes) {
|
|
return Ok(if path_extension(path).as_deref() == Some("mov") {
|
|
"video/quicktime"
|
|
} else {
|
|
"video/mp4"
|
|
});
|
|
}
|
|
if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
|
|
return Ok("video/webm");
|
|
}
|
|
return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string());
|
|
}
|
|
|
|
if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) {
|
|
Ok("audio/mpeg")
|
|
} else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {
|
|
Ok("audio/wav")
|
|
} else if bytes.starts_with(b"OggS") {
|
|
Ok("audio/ogg")
|
|
} else if bytes.starts_with(b"fLaC") {
|
|
Ok("audio/flac")
|
|
} else if is_avif(bytes) {
|
|
Err("音乐音效文件签名与登记类型不一致".to_string())
|
|
} else if is_iso_base_media(bytes) {
|
|
Ok("audio/mp4")
|
|
} else if looks_like_aac_adts(bytes) {
|
|
Ok("audio/aac")
|
|
} else {
|
|
Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string())
|
|
}
|
|
}
|
|
|
|
fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
|
|
let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?;
|
|
let lower = text.to_ascii_lowercase();
|
|
if !lower.contains("<svg") {
|
|
return Err("SVG 内容无效,无法预览".to_string());
|
|
}
|
|
let forbidden = [
|
|
"<script",
|
|
"<style",
|
|
"<foreignobject",
|
|
"<image",
|
|
"<!doctype",
|
|
"<!entity",
|
|
"&#",
|
|
"javascript:",
|
|
"file:",
|
|
"@import",
|
|
];
|
|
let external_url_probe = lower
|
|
.replace("http://www.w3.org/2000/svg", "")
|
|
.replace("http://www.w3.org/1999/xlink", "");
|
|
if forbidden.iter().any(|value| lower.contains(value))
|
|
|| external_url_probe.contains("http://")
|
|
|| external_url_probe.contains("https://")
|
|
|| contains_svg_event_handler(&lower)
|
|
|| contains_unsafe_svg_href(&lower)
|
|
|| contains_unsafe_svg_url(&lower)
|
|
{
|
|
return Err("SVG 包含脚本或外部资源引用,无法安全预览".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn contains_unsafe_svg_href(text: &str) -> bool {
|
|
let mut remaining = text;
|
|
while let Some(index) = remaining.find("href") {
|
|
let after_name = &remaining[index + 4..];
|
|
let Some(after_equals) = after_name.trim_start().strip_prefix('=') else {
|
|
remaining = after_name;
|
|
continue;
|
|
};
|
|
let value = after_equals.trim_start();
|
|
let value = value
|
|
.strip_prefix('\'')
|
|
.or_else(|| value.strip_prefix('"'))
|
|
.unwrap_or(value)
|
|
.trim_start();
|
|
if !value.starts_with('#') {
|
|
return true;
|
|
}
|
|
remaining = after_name;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn contains_unsafe_svg_url(text: &str) -> bool {
|
|
let mut remaining = text;
|
|
while let Some(index) = remaining.find("url(") {
|
|
let value = remaining[index + 4..].trim_start();
|
|
let value = value
|
|
.strip_prefix('\'')
|
|
.or_else(|| value.strip_prefix('"'))
|
|
.unwrap_or(value)
|
|
.trim_start();
|
|
if !value.starts_with('#') {
|
|
return true;
|
|
}
|
|
remaining = &remaining[index + 4..];
|
|
}
|
|
false
|
|
}
|
|
|
|
fn contains_svg_event_handler(text: &str) -> bool {
|
|
let bytes = text.as_bytes();
|
|
let mut index = 0usize;
|
|
while index + 3 < bytes.len() {
|
|
if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") {
|
|
let mut cursor = index + 3;
|
|
while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() {
|
|
cursor += 1;
|
|
}
|
|
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
|
cursor += 1;
|
|
}
|
|
if cursor < bytes.len() && bytes[cursor] == b'=' {
|
|
return true;
|
|
}
|
|
}
|
|
index += 1;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_iso_base_media(bytes: &[u8]) -> bool {
|
|
bytes.len() >= 12 && &bytes[4..8] == b"ftyp"
|
|
}
|
|
|
|
fn is_avif(bytes: &[u8]) -> bool {
|
|
is_iso_base_media(bytes)
|
|
&& (&bytes[8..12] == b"avif"
|
|
|| &bytes[8..12] == b"avis"
|
|
|| bytes[8..].windows(4).any(|brand| brand == b"avif"))
|
|
}
|
|
|
|
fn looks_like_mp3_frame(bytes: &[u8]) -> bool {
|
|
bytes.len() >= 4
|
|
&& bytes[0] == 0xff
|
|
&& bytes[1] & 0xe0 == 0xe0
|
|
&& bytes[1] & 0x06 != 0
|
|
&& bytes[2] & 0xf0 != 0xf0
|
|
&& bytes[2] & 0x0c != 0x0c
|
|
}
|
|
|
|
fn looks_like_id3(bytes: &[u8]) -> bool {
|
|
if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff {
|
|
return false;
|
|
}
|
|
let size_bytes = &bytes[6..10];
|
|
if size_bytes.iter().any(|byte| byte & 0x80 != 0) {
|
|
return false;
|
|
}
|
|
let tag_size = size_bytes
|
|
.iter()
|
|
.fold(0usize, |size, byte| (size << 7) | usize::from(*byte));
|
|
10usize
|
|
.checked_add(tag_size)
|
|
.is_some_and(|required| required <= bytes.len())
|
|
}
|
|
|
|
fn looks_like_aac_adts(bytes: &[u8]) -> bool {
|
|
bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0
|
|
}
|
|
|
|
fn path_extension(path: &str) -> Option<String> {
|
|
Path::new(path)
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(str::to_ascii_lowercase)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn text_preview_requires_utf8_and_a_supported_extension() {
|
|
let root = tempfile::tempdir().expect("temp root");
|
|
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
|
|
fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown");
|
|
fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text");
|
|
fs::write(root.path().join("docs/page.html"), "<h1>unsafe</h1>").expect("html");
|
|
|
|
let preview =
|
|
load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown");
|
|
assert_eq!(preview.media_type, "text/markdown");
|
|
assert!(preview.content.contains("正文"));
|
|
assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err());
|
|
assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn media_preview_accepts_safe_svg_and_rejects_active_svg() {
|
|
let root = tempfile::tempdir().expect("temp root");
|
|
fs::create_dir_all(root.path().join("assets")).expect("assets dir");
|
|
fs::write(
|
|
root.path().join("assets/icon.svg"),
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>",
|
|
)
|
|
.expect("svg");
|
|
fs::write(
|
|
root.path().join("assets/active.svg"),
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\"/>",
|
|
)
|
|
.expect("active svg");
|
|
fs::write(
|
|
root.path().join("assets/external.svg"),
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\"><use href = \"https://example.com/icon.svg#x\"/></svg>",
|
|
)
|
|
.expect("external svg");
|
|
|
|
let preview = load_local_project_media_preview(
|
|
root.path(),
|
|
"assets/icon.svg",
|
|
ProjectMediaPreviewKind::Art,
|
|
)
|
|
.expect("safe svg");
|
|
assert_eq!(preview.media_type, "image/svg+xml");
|
|
assert!(preview.data_url.starts_with("data:image/svg+xml;base64,"));
|
|
assert!(load_local_project_media_preview(
|
|
root.path(),
|
|
"assets/active.svg",
|
|
ProjectMediaPreviewKind::Art,
|
|
)
|
|
.is_err());
|
|
assert!(load_local_project_media_preview(
|
|
root.path(),
|
|
"assets/external.svg",
|
|
ProjectMediaPreviewKind::Art,
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn resource_preview_rejects_symlink_and_hardlink_files() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let root = tempfile::tempdir().expect("temp root");
|
|
let outside = tempfile::tempdir().expect("outside");
|
|
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
|
|
let source = outside.path().join("source.md");
|
|
fs::write(&source, "secret").expect("source");
|
|
symlink(&source, root.path().join("docs/link.md")).expect("symlink");
|
|
fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink");
|
|
|
|
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
|
|
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_media_preview_does_not_enter_base64_encoding() {
|
|
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
|
cancellation.cancel();
|
|
assert_eq!(
|
|
encode_project_resource_preview_data_url("audio/mpeg", b"ID3", &cancellation),
|
|
Err(
|
|
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
}
|