audit(docs/29): 全项目审计——修分片载入卸载截断高危、Sidebar memo 击穿与 Rust 资源/搜索/恢复上限等 20 余处

三线并行审计(编辑器核心/Rust 后端/服务与组件)+ App 编排层自审。
高危:分片载入未收尾时窗格卸载回写部分序列化,干净标签会被静默
截断——cleanup 补 muted 守卫,create 完成回调补取消守卫。
性能:gitSync/outline/收起回调稳定化保住 Sidebar memo;光标行列
rAF 合并;图片显示解析加缓存;导出图片并发解析;chunkLoader
偏移前缀表;围栏切点只认顶格(列表内嵌围栏等价回归实测)。
Rust:资源命令 async 化 + 32MB 上限;搜索 junction 越界校验;
recovery/workspace 条数与单条上限;启动坏库改名重建 + WAL;
validate 补 ext:: 黑名单与 ref 形态;init 先种 .gitignore
(含 .*.tmp-* 防崩溃残留被提交);游离 HEAD 不再猜分支。
验证:vitest 122+2、tsc、构建通过;Rust 过 rustfmt 解析、
validate 纯函数抽出实跑断言、命令对账 28=28。
This commit is contained in:
Muse
2026-11-14 18:00:31 +08:00
parent 527ca5ecc5
commit fb2315a2f3
28 changed files with 657 additions and 140 deletions

View File

@@ -88,6 +88,9 @@ pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> R
}
fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> {
if content.len() > 64 * 1024 * 1024 {
return Err("文档过大(超过 64MB),无法保存".to_string());
}
let target = PathBuf::from(&path);
let dir = target
.parent()
@@ -129,6 +132,9 @@ fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result
}
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
/// 资源文件大小上限(与 read_file_base64 对齐)。
const MAX_ASSET_BYTES: u64 = 32 * 1024 * 1024;
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
let doc = PathBuf::from(doc_path);
let dir = doc
@@ -248,34 +254,51 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
}
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
/// 异步 + spawn_blocking 且带大小上限:原先是同步命令整读任意文件,
/// 大文件会冻 UI 线程且内存整载(docs/29)。
#[tauri::command]
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
let src = PathBuf::from(&source);
let name = sanitize_file_name(
&src.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into()),
);
let dir = assets_dir(&doc_path)?;
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))
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let src = PathBuf::from(&source);
let name = sanitize_file_name(
&src.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into()),
);
let dir = assets_dir(&doc_path)?;
let meta = fs::metadata(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
if meta.len() > MAX_ASSET_BYTES {
return Err("文件过大(超过 32MB),无法作为资源复制".to_string());
}
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))
})
.await
.map_err(|e| e.to_string())?
}
/// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。
/// 审计修复:原先用 Vec<u8> 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字;
/// 改传 base64 字符串,体积与解析开销都降一个量级。
#[tauri::command]
pub fn save_bytes_to_assets(
pub async fn save_bytes_to_assets(
doc_path: String,
file_name: String,
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 = write_new_file(&dir, &name, &bytes)?;
Ok(relative_assets_path(&doc_path, &target))
tokio::task::spawn_blocking(move || {
let name = sanitize_file_name(&file_name);
let bytes = base64_decode(&base64)?;
if bytes.len() as u64 > MAX_ASSET_BYTES {
return Err("内容过大(超过 32MB),无法保存为资源文件".to_string());
}
let dir = assets_dir(&doc_path)?;
let target = write_new_file(&dir, &name, &bytes)?;
Ok(relative_assets_path(&doc_path, &target))
})
.await
.map_err(|e| e.to_string())?
}
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
@@ -361,11 +384,8 @@ pub fn list_dir(path: String) -> Result<Vec<DirEntry>, String> {
is_dir,
});
}
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
// 排序键预计算一次(原比较器每次比较都分配两个小写副本,大目录下浪费明显)
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
Ok(entries)
}
@@ -659,9 +679,13 @@ fn validate_branch(branch: &str) -> Result<String, String> {
let bad = b.len() > 100
|| b.starts_with('-')
|| b.starts_with('.')
|| b.starts_with('/')
|| b.ends_with('/')
|| b.ends_with('.')
|| b.ends_with(".lock")
|| b.contains("..")
|| b.contains("//")
|| b.contains("@{")
|| b.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\]".contains(c));
if bad {
return Err(format!("分支名不合法:{b}"));
@@ -675,7 +699,14 @@ fn validate_remote_url(url: &str) -> Result<String, String> {
if u.is_empty() {
return Ok(String::new());
}
if u.len() > 2000 || u.starts_with('-') || u.chars().any(|c| c.is_whitespace() || c.is_control()) {
// ext::/fd:: 是可执行 transport(fetch 时会跑命令),一律拒绝
let lower = u.to_lowercase();
if u.len() > 2000
|| u.starts_with('-')
|| lower.starts_with("ext::")
|| lower.starts_with("fd::")
|| u.chars().any(|c| c.is_whitespace() || c.is_control())
{
return Err("远程仓库地址不合法".to_string());
}
Ok(u.to_string())
@@ -773,18 +804,27 @@ pub async fn git_init_repo(root: String, remote_url: String, branch: String) ->
let b = validate_branch(&branch)?;
let url = validate_remote_url(&remote_url)?;
run_git(dir, &["init", "-b", &b])?;
if !url.is_empty() {
run_git(dir, &["remote", "add", "origin", &url])?;
}
// 同步会提交文件夹内全部未忽略文件:新建仓库时种一份保守的
// .gitignore(系统垃圾、临时文件、.env),避免敏感/无用文件被推走
// 同步会提交文件夹内全部未忽略文件:先种一份保守的 .gitignore
// (系统垃圾、临时文件、.env),避免敏感/无用文件被推走;
// .*.tmp-* 是编辑器原子写的临时文件,崩溃残留不应被同步提交。
// 播种放在绑远程之前,任一步失败也不会留下「有仓库没忽略规则」的半成品。
let gitignore = dir.join(".gitignore");
if !gitignore.exists() {
let _ = std::fs::write(
&gitignore,
".DS_Store\nThumbs.db\ndesktop.ini\n*.tmp\n~$*\n.env\n.env.*\n",
".DS_Store\nThumbs.db\ndesktop.ini\n*.tmp\n.*.tmp-*\n~$*\n.env\n.env.*\n",
);
}
if !url.is_empty() {
let has_origin = run_git(dir, &["remote"])
.map(|r| r.lines().any(|l| l.trim() == "origin"))
.unwrap_or(false);
if has_origin {
run_git(dir, &["remote", "set-url", "origin", &url])?;
} else {
run_git(dir, &["remote", "add", "origin", &url])?;
}
}
Ok(git_status_inner(&root))
})
.await
@@ -838,7 +878,11 @@ pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcom
run_git(dir, &args).map_err(|e| format!("提交失败:{e}"))?;
committed = true;
}
let branch = st.branch.clone().unwrap_or_else(|| "main".to_string());
// 游离 HEAD 时 branch 为空:不猜分支名(猜 main 会把成果推错地方),直接报错
let branch = match st.branch.clone() {
Some(b) => b,
None => return Err("当前处于游离 HEAD 状态,请先切回分支再同步".to_string()),
};
let mut pulled = false;
if has_remote {
let remote_ref = format!("origin/{branch}");

View File

@@ -8,6 +8,16 @@ use tauri::Manager;
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
pub struct AppDb(pub Mutex<rusqlite::Connection>);
/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> {
let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
let _ = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get::<_, String>(0));
conn.busy_timeout(std::time::Duration::from_millis(3000))
.map_err(|e| e.to_string())?;
storage::init_schema(&conn)?;
Ok(conn)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -15,9 +25,29 @@ pub fn run() {
.setup(|app| {
let data_dir = app.path().app_data_dir().expect("app data dir");
std::fs::create_dir_all(&data_dir).expect("create app data dir");
let conn = rusqlite::Connection::open(data_dir.join("new-markdown.db"))
.expect("open app database");
storage::init_schema(&conn).expect("init app database schema");
let db_path = data_dir.join("new-markdown.db");
let conn = match open_and_init(&db_path) {
Ok(c) => c,
Err(first_err) => {
// 数据库损坏/磁盘异常时不再启动即崩:把坏库改名备份后新建空库
// 继续启动。偏好/草稿会重置,但正文都在磁盘 .md 里,不受影响(docs/29)
eprintln!("app database unusable ({first_err}); backing it up and recreating");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = std::fs::rename(&db_path, data_dir.join(format!("new-markdown.db.broken-{ts}")));
let _ = std::fs::rename(
data_dir.join("new-markdown.db-wal"),
data_dir.join(format!("new-markdown.db-wal.broken-{ts}")),
);
let _ = std::fs::rename(
data_dir.join("new-markdown.db-shm"),
data_dir.join(format!("new-markdown.db-shm.broken-{ts}")),
);
open_and_init(&db_path).expect("open app database")
}
};
app.manage(AppDb(Mutex::new(conn)));
Ok(())
})

View File

@@ -70,6 +70,7 @@ fn scan_dir(
depth: usize,
needle: &str,
case_sensitive: bool,
root_canonical: &Path,
state: &mut ScanState,
out: &mut Vec<FileMatches>,
) {
@@ -81,6 +82,11 @@ fn scan_dir(
Ok(c) => c,
Err(_) => return,
};
// 越界即跳过:junction/符号链接指向工作区外时,去重防环挡不住(对它来说是「新目录」),
// 会把扫描配额耗在外部文件系统上并越界读用户文件(docs/29)
if !canonical.starts_with(root_canonical) {
return;
}
if !state.visited_dirs.insert(canonical) {
return;
}
@@ -100,13 +106,20 @@ fn scan_dir(
if name.starts_with('.') || name == "node_modules" {
continue;
}
scan_dir(&path, depth + 1, needle, case_sensitive, state, out);
scan_dir(&path, depth + 1, needle, case_sensitive, root_canonical, state, out);
} else if is_searchable(&path) {
if name.starts_with('.') {
continue;
}
state.scanned_files += 1;
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES {
continue;
match path.metadata() {
Ok(meta) => {
if meta.len() > MAX_FILE_BYTES {
continue;
}
}
// 元数据取不到时按跳过处理(原先是 fail-open,会整读任意大小文件)
Err(_) => continue,
}
let bytes = match fs::read(&path) {
Ok(b) => b,
@@ -147,6 +160,10 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec<Fi
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), 0, &needle, case_sensitive, &mut state, &mut out);
let root_canonical = match fs::canonicalize(root) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
scan_dir(Path::new(root), 0, &needle, case_sensitive, &root_canonical, &mut state, &mut out);
out
}

View File

@@ -111,6 +111,12 @@ pub fn workspace_add(conn: &Connection, path: &str, opened_at: i64) -> Result<()
params![path, opened_at],
)
.map_err(err)?;
// 只留最近 20 条(与 recent 的修剪口径对齐,避免列表只增不减)
conn.execute(
"DELETE FROM workspaces WHERE path NOT IN (SELECT path FROM workspaces ORDER BY opened_at DESC LIMIT 20)",
[],
)
.map_err(err)?;
Ok(())
}
@@ -167,6 +173,12 @@ pub fn recovery_list(conn: &Connection) -> Result<Vec<RecoveryEntry>, String> {
Ok(out)
}
/// 恢复记录单条上限:草稿是正文副本(.md 才是唯一正本),超限直接跳过、
/// 不报错——否则自动保存每 6 秒都会撞一次错误提示。
const RECOVERY_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024;
/// 恢复记录最多保留最近更新的条数。
const RECOVERY_KEEP: usize = 20;
pub fn recovery_save(
conn: &Connection,
doc_key: &str,
@@ -175,6 +187,9 @@ pub fn recovery_save(
content: &str,
updated_at: i64,
) -> Result<(), String> {
if content.len() > RECOVERY_MAX_CONTENT_BYTES {
return Ok(());
}
conn.execute(
"INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title,
@@ -182,6 +197,11 @@ pub fn recovery_save(
params![doc_key, path, title, content, updated_at],
)
.map_err(err)?;
conn.execute(
"DELETE FROM recovery WHERE doc_key NOT IN (SELECT doc_key FROM recovery ORDER BY updated_at DESC LIMIT ?1)",
params![RECOVERY_KEEP as i64],
)
.map_err(err)?;
Ok(())
}