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:
@@ -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}");
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user