diff --git a/docs/28-审计二轮.md b/docs/28-审计二轮.md new file mode 100644 index 0000000..cfd858d --- /dev/null +++ b/docs/28-审计二轮.md @@ -0,0 +1,65 @@ +# docs/28 — 审计二轮(聚焦 Git 同步,兼顾近期功能面) + +时间:2026-11-14 晚。大王已真机测过大部分功能,本轮为代码审计: +逐行复核 fadee6a 的 Rust 命令与前端接线,并抽查 docs/20–26 的 +关键不变量。先说结论:同步主链逻辑成立,发现 3 个中等问题已当场 +修复,4 个低问题记录待办,1 个沿袭项(CSP)状态未变。 + +## 已修复 + +### A1(中)git 命令参数的选项注入面 +分支名与远程地址来自用户输入,直接作 git 命令参数:以 `-` 开头 +会被 git 解析为选项(如给 remote add 塞 `-f` 立即抓取任意地址)。 +已在 Rust 入口加校验:validate_branch(简化 check-ref-format: +拒绝 `-`/`.` 开头、`..`、空白/控制字符与 `~^:?*[\]`、`.lock` +结尾、超长)与 validate_remote_url(拒绝 `-` 开头与空白/控制 +字符);git_init_repo、git_set_remote 强制走校验。 + +### A2(中)全文件夹同步的知情与兜底 +同步是 `add -A` 全文件夹提交:文件夹里的 .env、大二进制、系统 +垃圾都会被提交并推送,原始实现无任何提示与防线。两处补强: +①init 时若无 .gitignore,种一份保守默认(.DS_Store、Thumbs.db、 +desktop.ini、*.tmp、~$*、.env、.env.*);②设置弹窗文案明示同步 +范围为文件夹全部文件(.gitignore 除外)与首次同步全量提交。 + +### A3(中)设置弹窗的陈旧状态误判 +仓库状态(gitInfo)异步加载完成前点开设置,弹窗按「未启用」 +渲染:远程地址为空、显示初始分支,保存会误走 init 流程并在 +remote add 处报错。已给打开动作加守卫:状态未就绪时不开弹窗, +先刷新状态并在同步条给一句提示。 + +## 记录待办(本轮未改,附理由) + +- B1(低)空仓库接入「已有内容」的远程:本地从未提交、远程分支 + 已存在时,变基未出生 HEAD 的行为随 git 版本而异;失败时报错 + 且可恢复(fetch 已完成,手动 git pull 一次即可)。不在同步链 + 里引入 reset --hard 类危险动作,维持现状。 +- B2(低)git 子进程无超时:远程不可达(丢包型防火墙)时一次 + 同步可能挂数分钟,期间显示「正在同步…」且挡后续同步。后续 + 可给 run_git 加 spawn + 超时(建议 120 秒)。 +- B3(低)设置保存与进行中的同步可交错:set-url 与 fetch 各用 + 当时地址,结果自洽、错误可见,不做互斥。 +- B4(低)SSH 远程首次连接的主机密钥确认:无终端环境会直接 + 失败并显示错误,需先在终端连一次该主机。属预期行为。 + +## 沿袭项 + +- C1 CSP 仍为 null、Markdown 原始 HTML 的渲染面:docs/12 已 + 记录未动,本轮复核状态未变,建议阶段 7 发布前单独处理。 + +## 复核通过(无问题) + +- 同步链顺序 fetch→提交→变基→push;脏工作区不挡 pull;冲突 + rebase --abort 还原;身份缺失兜底 -c;无上游时 push -u。 +- 父仓库子目录不接管(show-toplevel 比对);命令数与注册数 + 对账 28=28;Windows 子进程 CREATE_NO_WINDOW 防闪窗。 +- 前端:同步重入守卫(ref)、定时租约互斥、拉取成功刷文件树、 + 工作区切换时状态复位与迟到响应丢弃、设置弹窗纳入 anyModal。 +- 抽查:chunkLoader 追加有 isCancelled 守卫;全仓无 TODO/ + FIXME/console.log 残留。 + +## 验证边界 + +- vitest 117+2 全过、tsc 与生产构建通过。 +- Rust 校验逻辑沙箱无 cargo 不可编译/单测,靠逐行复核;其实效 + 待下次 Windows 构建时一并确认(校验为纯函数,无宏与平台 API)。 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 819ae0c..3acdc51 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -649,6 +649,38 @@ pub struct GitSyncOutcome { pub summary: String, } +/// 分支名校验(简化版 check-ref-format):用户输入会作为 git 命令参数, +/// 以 - 开头会被解析成选项(选项注入),其余非法形态 git 自己也会拒绝。 +fn validate_branch(branch: &str) -> Result { + let b = branch.trim(); + if b.is_empty() { + return Ok("main".to_string()); + } + let bad = b.len() > 100 + || b.starts_with('-') + || b.starts_with('.') + || b.ends_with('/') + || b.ends_with(".lock") + || b.contains("..") + || b.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\]".contains(c)); + if bad { + return Err(format!("分支名不合法:{b}")); + } + Ok(b.to_string()) +} + +/// 远程地址校验:不得以 - 开头(选项注入)、不得含空白/控制字符。 +fn validate_remote_url(url: &str) -> Result { + let u = url.trim(); + 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()) { + return Err("远程仓库地址不合法".to_string()); + } + Ok(u.to_string()) +} + fn run_git(dir: &Path, args: &[&str]) -> Result { let mut cmd = std::process::Command::new("git"); cmd.args(args) @@ -738,10 +770,20 @@ pub async fn git_status(root: String) -> GitStatusInfo { pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result { tokio::task::spawn_blocking(move || { let dir = Path::new(&root); - let b = if branch.trim().is_empty() { "main" } else { branch.trim() }; - run_git(dir, &["init", "-b", b])?; - if !remote_url.trim().is_empty() { - run_git(dir, &["remote", "add", "origin", remote_url.trim()])?; + 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),避免敏感/无用文件被推走 + 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", + ); } Ok(git_status_inner(&root)) }) @@ -753,7 +795,8 @@ pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> pub async fn git_set_remote(root: String, remote_url: String) -> Result { tokio::task::spawn_blocking(move || { let dir = Path::new(&root); - let url = remote_url.trim(); + let url_owned = validate_remote_url(&remote_url)?; + let url = url_owned.as_str(); let has_origin = run_git(dir, &["remote"]).map(|r| r.lines().any(|l| l.trim() == "origin")).unwrap_or(false); if url.is_empty() { if has_origin { diff --git a/src/App.tsx b/src/App.tsx index ba0f2ff..7a074fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1501,7 +1501,17 @@ export default function App() { lastMessage: gitMsg?.text ?? null, lastIsError: gitMsg?.isError ?? false, onSyncNow: () => { void runGitSync(false) }, - onOpenSettings: () => { setGitSettingsError(null); setGitSettingsOpen(true) }, + onOpenSettings: () => { + // 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成 + // 未初始化(远程地址显示为空、保存走 init 流程报错) + if (!gitInfo) { + setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false }) + if (workspaceRoot) void refreshGitStatus(workspaceRoot) + return + } + setGitSettingsError(null) + setGitSettingsOpen(true) + }, } : undefined} /> diff --git a/src/components/GitSyncModal.tsx b/src/components/GitSyncModal.tsx index 4b50596..d7efcf3 100644 --- a/src/components/GitSyncModal.tsx +++ b/src/components/GitSyncModal.tsx @@ -32,6 +32,8 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval

同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容, 同步会停下并提示,不会强行覆盖,需要在该文件夹手动处理。 + 同步范围是文件夹内的全部文件(.gitignore 排除的除外);新建仓库时会自动生成 + 一份 .gitignore,排除系统垃圾、临时文件与 .env。