fix(audit): 审计问题全量修复(docs/12)

高危逻辑 A1-A10:saved 写盘快照、重载卸载回写闸门与期望值校验、
?file 无条件打开、偏好读后才写+防抖、depth=0 守卫、查找命中随编辑
作废、自动保存避让冲突标签、搜索防链接环、重 IO 命令异步化+上限。
样式:--panel 更正为 --bg-panel、新增 .input 基础样式、文档主题
纸面/字色成对、打印隐藏清单补全、paperTone 驱动源码明暗。
性能:状态栏/大纲按正文 memo、大纲延后提取、Sidebar memo、专注
装饰映射复用、查找防抖、图片 base64 过 IPC。
中危一批:关窗落盘后放行、回收站去向明示、GBK 搜索、create_new
落盘、Mermaid 渲染代次守卫、多行粘贴分段等。
回归:新增 tabkey/audit-fix 集成测试(E1 Tab 抢先定性排除),
vitest 71 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-13 23:34:19 +08:00
parent 78fbf6a91b
commit db81e60d71
26 changed files with 1064 additions and 228 deletions

View File

@@ -20,8 +20,23 @@ pub struct FileContent {
pub had_bom: bool,
}
/// 审计修复:重 IO 命令改 async 并在阻塞线程池执行,避免占用 UI 主线程;
/// 同时加大小上限,误开超大文件直接报错而不是把内存吃爆。
#[tauri::command]
pub fn read_text_file(path: String) -> Result<FileContent, String> {
pub async fn read_text_file(path: String) -> Result<FileContent, String> {
tauri::async_runtime::spawn_blocking(move || read_text_file_sync(path))
.await
.map_err(|e| format!("读取任务失败:{e}"))?
}
const MAX_TEXT_FILE_BYTES: u64 = 64 * 1024 * 1024;
fn read_text_file_sync(path: String) -> Result<FileContent, String> {
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > MAX_TEXT_FILE_BYTES {
return Err("文件过大(超过 64MB),已拒绝打开".to_string());
}
}
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
let (content, encoding, had_bom) = decode_bytes(&bytes);
let line_ending = if content.contains("\r\n") {
@@ -40,11 +55,12 @@ pub fn read_text_file(path: String) -> Result<FileContent, String> {
fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return (
String::from_utf8_lossy(rest).into_owned(),
"UTF-8".into(),
true,
);
if let Ok(s) = std::str::from_utf8(rest) {
return (s.to_string(), "UTF-8".into(), true);
}
// 带 BOM 但正文不是合法 UTF-8:按 GBK 解码,避免 lossy 把坏字节永久替换成 <20>
let (text, _, _) = encoding_rs::GBK.decode(rest);
return (text.into_owned(), "GBK".into(), true);
}
if bytes.starts_with(&[0xFF, 0xFE]) {
let (text, _, _) = encoding_rs::UTF_16LE.decode(bytes);
@@ -65,7 +81,13 @@ fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
/// 原子写:先写同目录临时文件并落盘,再替换目标。`keep_bom` 为真时补回 UTF-8 BOM。
#[tauri::command]
pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> {
pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || write_text_file_sync(path, content, keep_bom))
.await
.map_err(|e| format!("写入任务失败:{e}"))?
}
fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> {
let target = PathBuf::from(&path);
let dir = target
.parent()
@@ -76,7 +98,12 @@ pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<
.ok_or_else(|| "目标路径没有文件名".to_string())?
.to_string_lossy()
.into_owned();
let tmp = dir.join(format!(".{file_name}.tmp-{}", std::process::id()));
// 临时名带进程号 + 纳秒时间戳:自动保存与手动保存并发写同一文件时不共用临时文件
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = dir.join(format!(".{file_name}.tmp-{}-{nanos}", std::process::id()));
let mut data: Vec<u8> = Vec::with_capacity(content.len() + 3);
if keep_bom {
@@ -126,21 +153,34 @@ fn sanitize_file_name(name: &str) -> String {
let cleaned: String = base
.chars()
.map(|c| {
if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || c.is_control() {
'_'
} else {
c
}
})
.collect();
if cleaned.trim().is_empty() {
// Windows 会自动剥离结尾的点与空格,先在此对齐,避免登记名与落盘名不一致
let trimmed = cleaned.trim_end_matches(['.', ' ']);
let mut result = if trimmed.is_empty() {
"file".to_string()
} else {
cleaned
trimmed.to_string()
};
// Windows 保留设备名(CON/NUL 等)直接写会失败,加前缀避开
let stem_upper = result.split('.').next().unwrap_or("").to_ascii_uppercase();
let reserved = matches!(stem_upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem_upper.len() == 4
&& (stem_upper.starts_with("COM") || stem_upper.starts_with("LPT"))
&& stem_upper.as_bytes()[3].is_ascii_digit());
if reserved {
result = format!("_{result}");
}
result
}
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。落盘已改走 write_new_file,此函数保留备查。
#[allow(dead_code)]
fn dedupe_path(dir: &Path, file_name: &str) -> PathBuf {
let candidate = dir.join(file_name);
if !candidate.exists() {
@@ -175,6 +215,38 @@ fn relative_assets_path(doc_path: &str, target: &Path) -> String {
format!("{stem}.assets/{name}")
}
/// 以 create_new 方式逐个尝试候选名落盘:目标已存在就换下一个名字,
/// 绝不截断覆盖已有文件(消除 dedupe 先查后写的竞态,含兜底覆盖问题)。
fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, String> {
for candidate in std::iter::once(dir.join(file_name)).chain((1..1000).map(|i| {
let stem = Path::new(file_name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = Path::new(file_name)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
dir.join(format!("{stem}-{i}{ext}"))
})) {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(mut f) => {
use std::io::Write as _;
f.write_all(data)
.map_err(|e| format!("写入资源文件失败:{e}"))?;
return Ok(candidate);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("写入资源文件失败:{e}")),
}
}
Err("资源文件名冲突过多".to_string())
}
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
#[tauri::command]
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
@@ -185,25 +257,69 @@ pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String
.unwrap_or_else(|| "file".into()),
);
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::copy(&src, &target).map_err(|e| format!("复制资源文件失败:{e}"))?;
let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
let target = write_new_file(&dir, &name, &data)?;
Ok(relative_assets_path(&doc_path, &target))
}
/// 把字节内容(如粘贴的图片)写入文档资源目录,返回相对路径。
/// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。
/// 审计修复:原先用 Vec<u8> 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字;
/// 改传 base64 字符串,体积与解析开销都降一个量级。
#[tauri::command]
pub fn save_bytes_to_assets(
doc_path: String,
file_name: String,
bytes: Vec<u8>,
base64: String,
) -> Result<String, String> {
let name = sanitize_file_name(&file_name);
let bytes = base64_decode(&base64)?;
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::write(&target, &bytes).map_err(|e| format!("写入资源文件失败:{e}"))?;
let target = write_new_file(&dir, &name, &bytes)?;
Ok(relative_assets_path(&doc_path, &target))
}
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
fn value(b: u8) -> Result<u32, String> {
match b {
b'A'..=b'Z' => Ok((b - b'A') as u32),
b'a'..=b'z' => Ok((b - b'a' + 26) as u32),
b'0'..=b'9' => Ok((b - b'0' + 52) as u32),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err("base64 内容非法".to_string()),
}
}
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if clean.len() % 4 == 1 {
return Err("base64 长度非法".to_string());
}
let mut out = Vec::with_capacity(clean.len() / 4 * 3 + 3);
for chunk in clean.chunks(4) {
let mut n: u32 = 0;
let mut pad = 0;
for (i, &b) in chunk.iter().enumerate() {
if b == b'=' {
pad += 1;
n <<= 6;
} else {
n = (n << 6) | value(b)?;
}
let _ = i;
}
if chunk.len() < 4 {
n <<= 6 * (4 - chunk.len()) as u32;
}
out.push((n >> 16) as u8);
if pad < 2 && chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if pad < 1 && chunk.len() > 3 {
out.push(n as u8);
}
}
Ok(out)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirEntry {
@@ -289,17 +405,20 @@ pub fn stat_file(path: String) -> FileStat {
}
}
/// 新建空文本文件;目标已存在时报错(不覆盖)。
/// 新建空文本文件;目标已存在时报错(不覆盖)。用 create_new 保证检查与创建原子。
#[tauri::command]
pub fn create_text_file(path: String) -> Result<(), String> {
let p = Path::new(&path);
if p.exists() {
return Err("同名文件已存在".to_string());
}
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败:{e}"))?;
}
fs::write(p, "").map_err(|e| format!("创建文件失败:{e}"))
match fs::OpenOptions::new().write(true).create_new(true).open(p) {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
Err("同名文件已存在".to_string())
}
Err(e) => Err(format!("创建文件失败:{e}")),
}
}
#[tauri::command]
@@ -316,15 +435,19 @@ pub fn rename_path(from: String, to: String) -> Result<(), String> {
fs::rename(&from, &to).map_err(|e| format!("重命名失败:{e}"))
}
/// 删除到系统回收站(失败时退回永久删除仅限文件)。
/// 删除到系统回收站。返回实际去向:"trash" 或 "permanent"(回收站不可用时
/// 对文件退回永久删除)——审计修复:此前静默永久删除,用户以为可恢复;
/// 现在把去向回给前端明示。
#[tauri::command]
pub fn delete_to_trash(path: String) -> Result<(), String> {
pub fn delete_to_trash(path: String) -> Result<String, String> {
match trash::delete(&path) {
Ok(()) => Ok(()),
Ok(()) => Ok("trash".to_string()),
Err(e) => {
let p = Path::new(&path);
if p.is_file() {
fs::remove_file(p).map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}"))
fs::remove_file(p)
.map(|_| "permanent".to_string())
.map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}"))
} else {
Err(format!("删除失败:{e}"))
}
@@ -338,15 +461,14 @@ use crate::storage;
use crate::AppDb;
use tauri::State;
fn lock_db<'a>(
db: &'a State<'a, AppDb>,
) -> Result<std::sync::MutexGuard<'a, rusqlite::Connection>, String> {
db.0.lock().map_err(|_| "数据库锁损坏".to_string())
fn lock_db<'a>(db: &'a State<'a, AppDb>) -> std::sync::MutexGuard<'a, rusqlite::Connection> {
// 锁中毒(某命令持锁时 panic)后取回内部连接继续服务,而不是此后所有 DB 命令永久失败
db.0.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[tauri::command]
pub fn db_recent_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecentEntry>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_list(&conn, 30)
}
@@ -357,49 +479,49 @@ pub fn db_recent_add(
name: String,
opened_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_add(&conn, &path, &name, opened_at, 30)
}
#[tauri::command]
pub fn db_recent_remove(db: State<'_, AppDb>, path: String) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_remove(&conn, &path)
}
#[tauri::command]
pub fn db_recent_clear(db: State<'_, AppDb>) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_clear(&conn)
}
#[tauri::command]
pub fn db_workspace_list(db: State<'_, AppDb>) -> Result<Vec<String>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::workspace_list(&conn)
}
#[tauri::command]
pub fn db_workspace_add(db: State<'_, AppDb>, path: String, opened_at: i64) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::workspace_add(&conn, &path, opened_at)
}
#[tauri::command]
pub fn db_pref_get(db: State<'_, AppDb>, key: String) -> Result<Option<String>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::pref_get(&conn, &key)
}
#[tauri::command]
pub fn db_pref_set(db: State<'_, AppDb>, key: String, value: String) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::pref_set(&conn, &key, &value)
}
#[tauri::command]
pub fn db_recovery_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecoveryEntry>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recovery_list(&conn)
}
@@ -412,7 +534,7 @@ pub fn db_recovery_save(
content: String,
updated_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recovery_save(
&conn,
&doc_key,
@@ -425,7 +547,7 @@ pub fn db_recovery_save(
#[tauri::command]
pub fn db_recovery_delete(db: State<'_, AppDb>, doc_key: String) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recovery_delete(&conn, &doc_key)
}
@@ -457,11 +579,20 @@ fn base64_encode(data: &[u8]) -> String {
out
}
/// 读任意文件并以 base64 返回(导出图片内嵌、DOCX 导入共用)。
/// 读任意文件并以 base64 返回(导出图片内嵌、DOCX 导入共用)。异步执行 + 32MB 上限。
#[tauri::command]
pub fn read_file_base64(path: String) -> Result<String, String> {
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
Ok(base64_encode(&bytes))
pub async fn read_file_base64(path: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > 32 * 1024 * 1024 {
return Err("文件过大(超过 32MB)".to_string());
}
}
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
Ok(base64_encode(&bytes))
})
.await
.map_err(|e| format!("读取任务失败:{e}"))?
}
// ————————————————— 阶段 6:工作区全文搜索(逻辑在 search.rs) —————————————————
@@ -469,10 +600,14 @@ pub fn read_file_base64(path: String) -> Result<String, String> {
use crate::search;
#[tauri::command]
pub fn workspace_search(
pub async fn workspace_search(
root: String,
query: String,
case_sensitive: bool,
) -> Vec<search::FileMatches> {
search::search_workspace(&root, &query, case_sensitive)
tauri::async_runtime::spawn_blocking(move || {
search::search_workspace(&root, &query, case_sensitive)
})
.await
.unwrap_or_default()
}

View File

@@ -1,9 +1,17 @@
//! 工作区全文搜索(阶段 6):递归扫描 Markdown/文本文件做逐行包含匹配。
//! 工作区全文搜索:递归扫描 Markdown/文本文件做逐行包含匹配。
//! 纯逻辑、不依赖 Tauri 类型,命令层只转发;带上限保护(文件大小/结果数/单文件命中数)。
//!
//! 审计修复(2026-11-13):
//! - 原实现用 `is_dir()` 跟随符号链接无条件递归,工作区里有指向父目录的
//! junction/符号链接时会无限递归直至栈溢出——现按规范化路径去重 + 深度上限;
//! - 增加「已扫描文件总数」上限,无命中时也不会把整棵树无界扫完;
//! - 解码与读文件路径对齐:UTF-8 失败时按 GBK 解码(原先 lossy 解码导致
//! GBK 文档的中文永远搜不到)。
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
@@ -24,6 +32,8 @@ const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const MAX_FILES_WITH_MATCHES: usize = 300;
const MAX_MATCHES_PER_FILE: usize = 20;
const SNIPPET_CHARS: usize = 160;
const MAX_DEPTH: usize = 24;
const MAX_SCANNED_FILES: usize = 20_000;
fn is_searchable(path: &Path) -> bool {
matches!(
@@ -32,8 +42,46 @@ fn is_searchable(path: &Path) -> bool {
)
}
fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &mut Vec<FileMatches>) {
if out.len() >= MAX_FILES_WITH_MATCHES {
/// 与 commands::decode_bytes 对齐的文本解码:UTF-8(含 BOM)优先,否则 GBK。
fn decode_text(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
if let Ok(s) = std::str::from_utf8(rest) {
return s.to_string();
}
let (text, _, _) = encoding_rs::GBK.decode(rest);
return text.into_owned();
}
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => {
let (text, _, _) = encoding_rs::GBK.decode(bytes);
text.into_owned()
}
}
}
struct ScanState {
visited_dirs: HashSet<PathBuf>,
scanned_files: usize,
}
fn scan_dir(
dir: &Path,
depth: usize,
needle: &str,
case_sensitive: bool,
state: &mut ScanState,
out: &mut Vec<FileMatches>,
) {
if out.len() >= MAX_FILES_WITH_MATCHES || depth > MAX_DEPTH || state.scanned_files >= MAX_SCANNED_FILES {
return;
}
// 按规范化路径去重:符号链接/junction 指回已访目录时直接跳过,杜绝无限递归
let canonical = match fs::canonicalize(dir) {
Ok(c) => c,
Err(_) => return,
};
if !state.visited_dirs.insert(canonical) {
return;
}
let entries = match fs::read_dir(dir) {
@@ -43,7 +91,7 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m
let mut paths: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
paths.sort();
for path in paths {
if out.len() >= MAX_FILES_WITH_MATCHES {
if out.len() >= MAX_FILES_WITH_MATCHES || state.scanned_files >= MAX_SCANNED_FILES {
return;
}
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
@@ -52,8 +100,9 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m
if name.starts_with('.') || name == "node_modules" {
continue;
}
scan_dir(&path, query, needle, case_sensitive, out);
scan_dir(&path, depth + 1, needle, case_sensitive, state, out);
} else if is_searchable(&path) {
state.scanned_files += 1;
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES {
continue;
@@ -63,7 +112,7 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m
Ok(b) => b,
Err(_) => continue,
};
let text = String::from_utf8_lossy(&bytes);
let text = decode_text(&bytes);
let mut matches = Vec::new();
for (idx, line) in text.lines().enumerate() {
let hit = if case_sensitive {
@@ -96,7 +145,8 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec<Fi
return Vec::new();
}
let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() };
let mut state = ScanState { visited_dirs: HashSet::new(), scanned_files: 0 };
let mut out = Vec::new();
scan_dir(Path::new(root), query, &needle, case_sensitive, &mut out);
scan_dir(Path::new(root), 0, &needle, case_sensitive, &mut state, &mut out);
out
}