Repository navigation
Conversation
- 要删除或覆盖的文件先移到 cache/old/incremental-<时间戳>,任一步失败就全部还原,安装目录保持旧版本 - changes.json 可选的 base 字段(modified 文件在旧版本中的 SHA-256):本地文件不一致或缺失时不应用增量包 - 复制后逐个文件核对 SHA-256 - 增量更新失败时不再解压到 v版本号 兜底文件夹(增量包只含变动文件,无法单独运行) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
嘿——我发现了 11 个问题
给 AI 智能体的提示
请处理这次代码审查中的评论:
## 单条评论
### 评论 1
<location path="src-tauri/src/commands/update.rs" line_range="282" />
<code_context>
+impl UpdateJournal {
+ /// 将 path(文件或文件夹)移到备份目录下的 rel;无法移动的文件改为复制一份
+ fn set_aside(&mut self, rel: &std::path::Path, path: &std::path::Path) -> Result<(), String> {
+ let dest = self.backup.join(rel);
+ if let Some(parent) = dest.parent() {
+ std::fs::create_dir_all(parent)
</code_context>
<issue_to_address>
**备份路径逃逸出缓存目录**
当下载的 changes.json 包含带有父目录组件的已删除路径时,`set_aside` 会将清单控制的路径分别与 `target` 和 `backup` 拼接,却没有拒绝 `..`。因此,精心构造的已删除路径会逃逸出备份目录,重命名操作可能替换预期缓存区域之外的文件。
请将清单路径验证为安全的相对路径,并在与任一根路径拼接之前拒绝绝对路径和任何 `..` 组件。
另见 `src-tauri/src/commands/update.rs:392-394`。
</issue_to_address>
### 评论 2
<location path="src-tauri/src/commands/update.rs" line_range="299-306" />
<code_context>
- // 2. 复制新包内容到目标目录(覆盖)- 这一步必须执行
- copy_dir_contents(&extract_dir, &target_dir, Some(&["changes.json"]))?;
+ fn rollback(&self) {
+ for path in self.created.iter().rev() {
+ if let Err(e) = remove_path(path) {
+ warn!("还原:无法删除新增的 [{}]: {}", path.display(), e);
+ }
+ }
+ for (path, saved) in self.set_aside.iter().rev() {
+ if path.exists() {
+ let _ = remove_path(path);
+ }
+ let restored = std::fs::rename(saved, path).is_ok()
</code_context>
<issue_to_address>
**回滚会覆盖并发更新**
当两个更新器进程在重叠时间内对同一目标目录应用补丁,而其中一个在另一个写入文件后执行回滚时,该更新的回滚会删除当前目标路径并恢复其保存的副本,却不会检查另一个更新是否已修改这些路径。如果两个应用进程同时更新同一安装目录,其中一个在另一个写入文件后失败,失败的进程可能会删除或覆盖另一个进程成功完成的更改,导致安装内容混杂或不完整。
请使用跨进程共享的锁串行化增量更新,并以目标安装目录作为锁的键。
</issue_to_address>
### 评论 3
<location path="src-tauri/src/commands/update.rs" line_range="415-418" />
<code_context>
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("无法创建目录 [{}]: {}", parent.display(), e))?;
+ }
+ std::fs::copy(&src, &dst)
+ .map_err(|e| format!("无法复制文件 [{}]: {}", dst.display(), e))?;
+ if !existed {
+ journal.created.push(dst.clone());
+ }
+ if sha256_file(&dst)? != sha256_file(&src)? {
</code_context>
<issue_to_address>
**回滚后仍残留不完整文件**
复制一个此前不存在的文件时,如果在目标文件创建或写入部分内容后复制失败,`journal.created` 只有在 `std::fs::copy` 成功后才会更新,因此 `rollback` 不会删除这个不完整的目标文件,失败的更新会在安装目录中留下多余文件。
请在复制之前将新目标路径记录到 `journal.created`,或者在复制失败时明确删除该文件。
</issue_to_address>
### 评论 4
<location path="src-tauri/src/commands/update.rs" line_range="287-292" />
<code_context>
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("无法创建备份目录 [{}]: {}", parent.display(), e))?;
+ }
+ if let Err(e) = std::fs::rename(path, &dest) {
+ if !path.is_file() {
+ return Err(format!("无法备份 [{}]: {}", path.display(), e));
}
+ std::fs::copy(path, &dest)
+ .map_err(|e| format!("无法备份 [{}]: {}", path.display(), e))?;
}
+ self.set_aside.push((path.to_path_buf(), dest));
</code_context>
<issue_to_address>
**已删除文件仍保留在安装目录中**
当将已删除文件重命名到备份位置失败,或备份文件失败时,`UpdateJournal::set_aside` 在复制文件后仍会将源文件留在原处;与此同时,`apply_incremental_update` 会记录并忽略备份错误。因此,更新会返回成功,但列在删除清单中的文件仍保留在安装目录中。
请在成功复制备份后删除源文件,并向上传递备份错误,使更新回滚而不是继续执行。
另见 `src-tauri/src/commands/update.rs:395-398`。
</issue_to_address>
### 评论 5
<location path="src-tauri/src/commands/update.rs" line_range="353-354" />
<code_context>
+ target: &std::path::Path,
+ base: &std::collections::HashMap<String, String>,
+) -> Vec<String> {
+ let mut differ: Vec<String> = base
+ .iter()
+ .filter(|(rel, sha)| {
+ sha256_file(&target.join(normalize_relative_path(rel)))
</code_context>
<issue_to_address>
**本地修改绕过验证**
当 `changes.json` 没有在其 `base` 映射中为 `modified` 列出的某个路径提供条目时,`files_not_at_base` 只检查 `base` 中已有的条目,因此被遗漏的已修改文件会通过验证,补丁随后覆盖本地修改。
请在应用补丁之前,要求每个已修改路径都必须有对应的 base 哈希。
另见 `src-tauri/src/commands/update.rs:355`。
</issue_to_address>
### 评论 6
<location path="src-tauri/src/commands/update.rs" line_range="427-429" />
<code_context>
+ Ok(())
+ };
+ let result = run();
+ if let Err(e) = &result {
+ warn!("增量更新失败,已还原为原版本: {}", e);
+ journal.rollback();
+ }
+ result
</code_context>
<issue_to_address>
**更新中断后会留下混杂文件**
当进程或机器在增量文件更改开始后、更新完成前停止时,`UpdateJournal` 只存在于内存中,因此进程退出后不会执行回滚;启动时的清理操作可能会删除 `cache/old` 下保存的文件,导致安装目录处于混合版本状态。
请持久化日志,并在启动清理删除备份之前,恢复或回滚中断的更新。
另见 `src-tauri/src/commands/update.rs:385-389`。
</issue_to_address>
### 评论 7
<location path="src-tauri/src/commands/update.rs" line_range="264-265" />
<code_context>
+ std::path::Path::new(&extract_dir),
+ std::path::Path::new(&target_dir),
+ &deleted_files,
+ &old_dir.join(format!("incremental-{}", stamp)),
+ )?;
+ info!("apply_incremental_update success");
+ Ok(())
</code_context>
<issue_to_address>
**并发清理会删除回滚文件**
当下载或另一次更新将文件移入旧文件目录时,增量更新可能已在那里暂存了备份。增量更新会将回滚文件存放在 `cache/old/incremental-<timestamp>` 下,而 `move_to_old_folder` 在移动任何文件之前会删除整个 `cache/old` 目录。如果下载或另一次更新在第一次更新执行期间调用该辅助函数,第一次更新之后若失败便无法恢复文件,导致安装目录内容混杂。
请将事务备份存放在通用旧文件清理操作不会删除的目录中,或者让清理操作与活动更新串行执行。
</issue_to_address>
### 评论 8
<location path="src-tauri/src/commands/update.rs" line_range="263-264" />
<code_context>
+ apply_incremental(
+ std::path::Path::new(&extract_dir),
+ std::path::Path::new(&target_dir),
+ &deleted_files,
+ &old_dir.join(format!("incremental-{}", stamp)),
+ )?;
+ info!("apply_incremental_update success");
</code_context>
<issue_to_address>
**更新成功后备份会丢失**
当增量更新成功且下载的 ZIP 被移入旧文件目录时,`move_to_old_folder` 会清空 `cache/old`,其中也包括刚创建的增量备份,因此成功更新后没有可用于恢复的已保存文件。
请将增量备份存放在 `move_to_old_folder` 清理范围之外,或者在该辅助函数清理旧文件夹时保留这些备份。
另见 `src-tauri/src/commands/update.rs:265`。
</issue_to_address>
### 评论 9
<location path="src/services/updateService.ts" line_range="1041" />
<code_context>
const changesJson = await invoke<ChangesJson | null>('check_changes_json', {
extractDir,
});
+ incremental = changesJson !== null;
if (changesJson) {
</code_context>
<issue_to_address>
**损坏的补丁看起来仍可使用**
当解压后的增量归档包含无法读取或解析的 `changes.json` 时,`check_changes_json` 会在设置 `incremental` 之前失败,因此 catch 会将补丁视为完整安装包并调用 `fallback_update`,把其中不完整的内容呈现为可用的版本文件夹。
请在解析之前检测 `changes.json` 是否存在,并阻止格式错误的增量包走完整安装包的回退流程。
另见 `src/services/updateService.ts:1090-1094`。
</issue_to_address>
### 评论 10
<location path="src/services/updateService.ts" line_range="1090" />
<code_context>
} catch (error) {
log.error('更新安装失败:', error);
+ if (incremental) {
+ // 安装目录已还原;增量包只含变动的文件,解压到兜底文件夹也无法运行
+ await invoke('cleanup_extract_dir', { extractDir }).catch(() => {});
</code_context>
<issue_to_address>
**已提交的更新却报告失败**
当增量应用成功,但清理解压目录或移动 ZIP 失败时,catch 仍会看到 `incremental` 为 true,并将应用完成后的错误当作应用失败处理。因此,即使目标目录已经包含新版本,调用方仍会收到安装错误。
请将增量应用错误处理器的作用范围限制在应用操作本身,并在应用成功后分别处理清理或 ZIP 移动失败。
</issue_to_address>
### 评论 11
<location path="src-tauri/src/commands/update.rs" line_range="328-329" />
<code_context>
+ let path = entry
+ .map_err(|e| format!("无法读取目录条目: {}", e))?
+ .path();
+ if path.is_dir() {
+ stack.push(path);
+ } else if let Ok(rel) = path.strip_prefix(dir) {
+ out.push(rel.to_path_buf());
</code_context>
<issue_to_address>
**符号链接会使提取目录扫描越界**
当增量归档包含目录符号链接时,`relative_files` 会跟随目录符号链接,可能扫描提取目录之外的内容;符号链接循环也可能导致扫描失败或耗尽资源。
扫描已提取文件时,请勿进入符号链接目录。
</issue_to_address>Original comment in English
Hey - I've found 11 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src-tauri/src/commands/update.rs" line_range="282" />
<code_context>
+impl UpdateJournal {
+ /// 将 path(文件或文件夹)移到备份目录下的 rel;无法移动的文件改为复制一份
+ fn set_aside(&mut self, rel: &std::path::Path, path: &std::path::Path) -> Result<(), String> {
+ let dest = self.backup.join(rel);
+ if let Some(parent) = dest.parent() {
+ std::fs::create_dir_all(parent)
</code_context>
<issue_to_address>
**Backup paths escape cache**
When a downloaded changes.json contains a deleted path with parent-directory components, `set_aside` joins manifest-controlled paths to both `target` and `backup` without rejecting `..`. A crafted deleted path therefore escapes the backup directory, and the rename can replace a file outside the intended cache area.
Validate manifest paths as safe relative paths and reject absolute paths and any `..` component before joining them to either root.
Also at `src-tauri/src/commands/update.rs:392-394`.
</issue_to_address>
### Comment 2
<location path="src-tauri/src/commands/update.rs" line_range="299-306" />
<code_context>
- // 2. 复制新包内容到目标目录(覆盖)- 这一步必须执行
- copy_dir_contents(&extract_dir, &target_dir, Some(&["changes.json"]))?;
+ fn rollback(&self) {
+ for path in self.created.iter().rev() {
+ if let Err(e) = remove_path(path) {
+ warn!("还原:无法删除新增的 [{}]: {}", path.display(), e);
+ }
+ }
+ for (path, saved) in self.set_aside.iter().rev() {
+ if path.exists() {
+ let _ = remove_path(path);
+ }
+ let restored = std::fs::rename(saved, path).is_ok()
</code_context>
<issue_to_address>
**Rollback clobbers concurrent updates**
When two updater processes apply patches to the same target directory at overlapping times and one rolls back after the other has written files, one update’s rollback removes current destination paths and restores its saved copies without checking whether another update has since changed them. If two app processes update the same installation concurrently and one fails after the other writes files, the failing process can erase or overwrite the other process’s successful changes, leaving a mixed or incomplete installation.
Serialize incremental updates with a lock shared across processes and keyed to the target installation directory.
</issue_to_address>
### Comment 3
<location path="src-tauri/src/commands/update.rs" line_range="415-418" />
<code_context>
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("无法创建目录 [{}]: {}", parent.display(), e))?;
+ }
+ std::fs::copy(&src, &dst)
+ .map_err(|e| format!("无法复制文件 [{}]: {}", dst.display(), e))?;
+ if !existed {
+ journal.created.push(dst.clone());
+ }
+ if sha256_file(&dst)? != sha256_file(&src)? {
</code_context>
<issue_to_address>
**Partial files survive rollback**
When copying a previously absent file fails after creating or writing part of its destination, `journal.created` is updated only after `std::fs::copy` succeeds, so `rollback` does not remove the partial destination and the failed update leaves an extra file in the installation.
Record the new destination in `journal.created` before copying, or remove it explicitly whenever the copy fails.
</issue_to_address>
### Comment 4
<location path="src-tauri/src/commands/update.rs" line_range="287-292" />
<code_context>
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("无法创建备份目录 [{}]: {}", parent.display(), e))?;
+ }
+ if let Err(e) = std::fs::rename(path, &dest) {
+ if !path.is_file() {
+ return Err(format!("无法备份 [{}]: {}", path.display(), e));
}
+ std::fs::copy(path, &dest)
+ .map_err(|e| format!("无法备份 [{}]: {}", path.display(), e))?;
}
+ self.set_aside.push((path.to_path_buf(), dest));
</code_context>
<issue_to_address>
**Deleted files remain installed**
When renaming a deleted file into the backup fails, or backing it up fails, `UpdateJournal::set_aside` leaves the source in place after copying it, while `apply_incremental_update` logs and ignores backup errors; the update returns success with files listed for deletion still installed.
Remove the source after a successful backup copy and propagate backup failures so the update rolls back instead of continuing.
Also at `src-tauri/src/commands/update.rs:395-398`.
</issue_to_address>
### Comment 5
<location path="src-tauri/src/commands/update.rs" line_range="353-354" />
<code_context>
+ target: &std::path::Path,
+ base: &std::collections::HashMap<String, String>,
+) -> Vec<String> {
+ let mut differ: Vec<String> = base
+ .iter()
+ .filter(|(rel, sha)| {
+ sha256_file(&target.join(normalize_relative_path(rel)))
</code_context>
<issue_to_address>
**Local edits bypass validation**
When `changes.json` omits a path listed in `modified` from its `base` map, `files_not_at_base` checks only entries present in `base`, so the omitted modified file passes validation and the patch overwrites the local edit.
Require a base hash for every modified path before applying the patch.
Also at `src-tauri/src/commands/update.rs:355`.
</issue_to_address>
### Comment 6
<location path="src-tauri/src/commands/update.rs" line_range="427-429" />
<code_context>
+ Ok(())
+ };
+ let result = run();
+ if let Err(e) = &result {
+ warn!("增量更新失败,已还原为原版本: {}", e);
+ journal.rollback();
+ }
+ result
</code_context>
<issue_to_address>
**Interrupted updates leave mixed files**
When the process or machine stops after incremental file changes begin but before the update completes, `UpdateJournal` exists only in memory, so no rollback runs after a process exit; on startup, cleanup can delete the saved files under `cache/old`, leaving the installation in a mixed version.
Persist the journal and recover or roll back an interrupted update before startup cleanup removes its backups.
Also at `src-tauri/src/commands/update.rs:385-389`.
</issue_to_address>
### Comment 7
<location path="src-tauri/src/commands/update.rs" line_range="264-265" />
<code_context>
+ std::path::Path::new(&extract_dir),
+ std::path::Path::new(&target_dir),
+ &deleted_files,
+ &old_dir.join(format!("incremental-{}", stamp)),
+ )?;
+ info!("apply_incremental_update success");
+ Ok(())
</code_context>
<issue_to_address>
**Concurrent cleanup removes rollback files**
When a download or another update moves a file to old while an incremental update has staged backups there, an incremental update stores its rollback files under `cache/old/incremental-<timestamp>`, while `move_to_old_folder` removes the entire `cache/old` tree before moving any file. If a download or another update calls that helper while the first update is applying, the first update's later failure cannot restore its files, leaving the installation mixed.
Keep transaction backups outside the directory that general old-file cleanup removes, or serialize cleanup with active updates.
</issue_to_address>
### Comment 8
<location path="src-tauri/src/commands/update.rs" line_range="263-264" />
<code_context>
+ apply_incremental(
+ std::path::Path::new(&extract_dir),
+ std::path::Path::new(&target_dir),
+ &deleted_files,
+ &old_dir.join(format!("incremental-{}", stamp)),
+ )?;
+ info!("apply_incremental_update success");
</code_context>
<issue_to_address>
**Successful updates lose backups**
When an incremental update succeeds and the downloaded ZIP is moved to the old folder, `move_to_old_folder` clears `cache/old`, including the incremental backups just created there, so the successful update leaves no saved files available for restoration.
Store incremental backups outside the directory cleared by `move_to_old_folder`, or preserve them when that helper clears the old folder.
Also at `src-tauri/src/commands/update.rs:265`.
</issue_to_address>
### Comment 9
<location path="src/services/updateService.ts" line_range="1041" />
<code_context>
const changesJson = await invoke<ChangesJson | null>('check_changes_json', {
extractDir,
});
+ incremental = changesJson !== null;
if (changesJson) {
</code_context>
<issue_to_address>
**Broken patches appear usable**
When an extracted incremental archive contains a `changes.json` that cannot be read or parsed, `check_changes_json` fails before `incremental` is set, so the catch treats the patch as a full package and calls `fallback_update`, presenting its partial contents as a usable version folder.
Detect the presence of `changes.json` before parsing it and prevent malformed incremental packages from taking the full-package fallback.
Also at `src/services/updateService.ts:1090-1094`.
</issue_to_address>
### Comment 10
<location path="src/services/updateService.ts" line_range="1090" />
<code_context>
} catch (error) {
log.error('更新安装失败:', error);
+ if (incremental) {
+ // 安装目录已还原;增量包只含变动的文件,解压到兜底文件夹也无法运行
+ await invoke('cleanup_extract_dir', { extractDir }).catch(() => {});
</code_context>
<issue_to_address>
**Committed updates report failure**
When incremental application succeeds but cleanup of the extracted directory or moving the ZIP fails, the catch still sees `incremental` as true and treats the post-apply error as an apply failure, so the caller receives an installation error even though the target already contains the new version.
Limit the incremental-apply error handler to the apply operation and handle cleanup or ZIP-move failures separately after a successful apply.
</issue_to_address>
### Comment 11
<location path="src-tauri/src/commands/update.rs" line_range="328-329" />
<code_context>
+ let path = entry
+ .map_err(|e| format!("无法读取目录条目: {}", e))?
+ .path();
+ if path.is_dir() {
+ stack.push(path);
+ } else if let Ok(rel) = path.strip_prefix(dir) {
+ out.push(rel.to_path_buf());
</code_context>
<issue_to_address>
**Symlinks escape extraction scan**
When an incremental archive contains a directory symlink, `relative_files` follows directory symlinks and can scan outside the extraction tree; a symlink cycle can also make the scan fail or exhaust resources.
Do not descend into symlink directories when scanning extracted files.
</issue_to_address>| impl UpdateJournal { | ||
| /// 将 path(文件或文件夹)移到备份目录下的 rel;无法移动的文件改为复制一份 | ||
| fn set_aside(&mut self, rel: &std::path::Path, path: &std::path::Path) -> Result<(), String> { | ||
| let dest = self.backup.join(rel); |
There was a problem hiding this comment.
🟠 高 · 备份路径逃逸出缓存目录
当下载的 changes.json 包含带有父目录组件的已删除路径时,set_aside 会将清单控制的路径分别与 target 和 backup 拼接,却没有拒绝 ..。因此,精心构造的已删除路径会逃逸出备份目录,重命名操作可能替换预期缓存区域之外的文件。
请将清单路径验证为安全的相对路径,并在与任一根路径拼接之前拒绝绝对路径和任何 .. 组件。
另见 src-tauri/src/commands/update.rs:392-394。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 282 行:
**备份路径逃逸出缓存目录**
当下载的 changes.json 包含带有父目录组件的已删除路径时,`set_aside` 会将清单控制的路径分别与 `target` 和 `backup` 拼接,却没有拒绝 `..`。因此,精心构造的已删除路径会逃逸出备份目录,重命名操作可能替换预期缓存区域之外的文件。
请将清单路径验证为安全的相对路径,并在与任一根路径拼接之前拒绝绝对路径和任何 `..` 组件。
另见 `src-tauri/src/commands/update.rs:392-394`。Original comment in English
🟠 High · Backup paths escape cache
When a downloaded changes.json contains a deleted path with parent-directory components, set_aside joins manifest-controlled paths to both target and backup without rejecting ... A crafted deleted path therefore escapes the backup directory, and the rename can replace a file outside the intended cache area.
Validate manifest paths as safe relative paths and reject absolute paths and any .. component before joining them to either root.
Also at src-tauri/src/commands/update.rs:392-394.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at line 282:
**Backup paths escape cache**
When a downloaded changes.json contains a deleted path with parent-directory components, `set_aside` joins manifest-controlled paths to both `target` and `backup` without rejecting `..`. A crafted deleted path therefore escapes the backup directory, and the rename can replace a file outside the intended cache area.
Validate manifest paths as safe relative paths and reject absolute paths and any `..` component before joining them to either root.
Also at `src-tauri/src/commands/update.rs:392-394`.| for path in self.created.iter().rev() { | ||
| if let Err(e) = remove_path(path) { | ||
| warn!("还原:无法删除新增的 [{}]: {}", path.display(), e); | ||
| } | ||
| } | ||
| for (path, saved) in self.set_aside.iter().rev() { | ||
| if path.exists() { | ||
| let _ = remove_path(path); |
There was a problem hiding this comment.
🟠 高 · 回滚会覆盖并发更新
当两个更新器进程在重叠时间内对同一目标目录应用补丁,而其中一个在另一个写入文件后执行回滚时,该更新的回滚会删除当前目标路径并恢复其保存的副本,却不会检查另一个更新是否已修改这些路径。如果两个应用进程同时更新同一安装目录,其中一个在另一个写入文件后失败,失败的进程可能会删除或覆盖另一个进程成功完成的更改,导致安装内容混杂或不完整。
请使用跨进程共享的锁串行化增量更新,并以目标安装目录作为锁的键。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 299-306 行:
**回滚会覆盖并发更新**
当两个更新器进程在重叠时间内对同一目标目录应用补丁,而其中一个在另一个写入文件后执行回滚时,该更新的回滚会删除当前目标路径并恢复其保存的副本,却不会检查另一个更新是否已修改这些路径。如果两个应用进程同时更新同一安装目录,其中一个在另一个写入文件后失败,失败的进程可能会删除或覆盖另一个进程成功完成的更改,导致安装内容混杂或不完整。
请使用跨进程共享的锁串行化增量更新,并以目标安装目录作为锁的键。Original comment in English
🟠 High · Rollback clobbers concurrent updates
When two updater processes apply patches to the same target directory at overlapping times and one rolls back after the other has written files, one update’s rollback removes current destination paths and restores its saved copies without checking whether another update has since changed them. If two app processes update the same installation concurrently and one fails after the other writes files, the failing process can erase or overwrite the other process’s successful changes, leaving a mixed or incomplete installation.
Serialize incremental updates with a lock shared across processes and keyed to the target installation directory.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 299-306:
**Rollback clobbers concurrent updates**
When two updater processes apply patches to the same target directory at overlapping times and one rolls back after the other has written files, one update’s rollback removes current destination paths and restores its saved copies without checking whether another update has since changed them. If two app processes update the same installation concurrently and one fails after the other writes files, the failing process can erase or overwrite the other process’s successful changes, leaving a mixed or incomplete installation.
Serialize incremental updates with a lock shared across processes and keyed to the target installation directory.| std::fs::copy(&src, &dst) | ||
| .map_err(|e| format!("无法复制文件 [{}]: {}", dst.display(), e))?; | ||
| if !existed { | ||
| journal.created.push(dst.clone()); |
There was a problem hiding this comment.
🟡 中 · 回滚后仍残留不完整文件
复制一个此前不存在的文件时,如果在目标文件创建或写入部分内容后复制失败,journal.created 只有在 std::fs::copy 成功后才会更新,因此 rollback 不会删除这个不完整的目标文件,失败的更新会在安装目录中留下多余文件。
请在复制之前将新目标路径记录到 journal.created,或者在复制失败时明确删除该文件。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 415-418 行:
**回滚后仍残留不完整文件**
复制一个此前不存在的文件时,如果在目标文件创建或写入部分内容后复制失败,`journal.created` 只有在 `std::fs::copy` 成功后才会更新,因此 `rollback` 不会删除这个不完整的目标文件,失败的更新会在安装目录中留下多余文件。
请在复制之前将新目标路径记录到 `journal.created`,或者在复制失败时明确删除该文件。Original comment in English
🟡 Medium · Partial files survive rollback
When copying a previously absent file fails after creating or writing part of its destination, journal.created is updated only after std::fs::copy succeeds, so rollback does not remove the partial destination and the failed update leaves an extra file in the installation.
Record the new destination in journal.created before copying, or remove it explicitly whenever the copy fails.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 415-418:
**Partial files survive rollback**
When copying a previously absent file fails after creating or writing part of its destination, `journal.created` is updated only after `std::fs::copy` succeeds, so `rollback` does not remove the partial destination and the failed update leaves an extra file in the installation.
Record the new destination in `journal.created` before copying, or remove it explicitly whenever the copy fails.| if let Err(e) = std::fs::rename(path, &dest) { | ||
| if !path.is_file() { | ||
| return Err(format!("无法备份 [{}]: {}", path.display(), e)); | ||
| } | ||
| std::fs::copy(path, &dest) | ||
| .map_err(|e| format!("无法备份 [{}]: {}", path.display(), e))?; |
There was a problem hiding this comment.
🟠 高 · 已删除文件仍保留在安装目录中
当将已删除文件重命名到备份位置失败,或备份文件失败时,UpdateJournal::set_aside 在复制文件后仍会将源文件留在原处;与此同时,apply_incremental_update 会记录并忽略备份错误。因此,更新会返回成功,但列在删除清单中的文件仍保留在安装目录中。
请在成功复制备份后删除源文件,并向上传递备份错误,使更新回滚而不是继续执行。
另见 src-tauri/src/commands/update.rs:395-398。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 287-292 行:
**已删除文件仍保留在安装目录中**
当将已删除文件重命名到备份位置失败,或备份文件失败时,`UpdateJournal::set_aside` 在复制文件后仍会将源文件留在原处;与此同时,`apply_incremental_update` 会记录并忽略备份错误。因此,更新会返回成功,但列在删除清单中的文件仍保留在安装目录中。
请在成功复制备份后删除源文件,并向上传递备份错误,使更新回滚而不是继续执行。
另见 `src-tauri/src/commands/update.rs:395-398`。Original comment in English
🟠 High · Deleted files remain installed
When renaming a deleted file into the backup fails, or backing it up fails, UpdateJournal::set_aside leaves the source in place after copying it, while apply_incremental_update logs and ignores backup errors; the update returns success with files listed for deletion still installed.
Remove the source after a successful backup copy and propagate backup failures so the update rolls back instead of continuing.
Also at src-tauri/src/commands/update.rs:395-398.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 287-292:
**Deleted files remain installed**
When renaming a deleted file into the backup fails, or backing it up fails, `UpdateJournal::set_aside` leaves the source in place after copying it, while `apply_incremental_update` logs and ignores backup errors; the update returns success with files listed for deletion still installed.
Remove the source after a successful backup copy and propagate backup failures so the update rolls back instead of continuing.
Also at `src-tauri/src/commands/update.rs:395-398`.| let mut differ: Vec<String> = base | ||
| .iter() |
There was a problem hiding this comment.
🟠 高 · 本地修改绕过验证
当 changes.json 没有在其 base 映射中为 modified 列出的某个路径提供条目时,files_not_at_base 只检查 base 中已有的条目,因此被遗漏的已修改文件会通过验证,补丁随后覆盖本地修改。
请在应用补丁之前,要求每个已修改路径都必须有对应的 base 哈希。
另见 src-tauri/src/commands/update.rs:355。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 353-354 行:
**本地修改绕过验证**
当 `changes.json` 没有在其 `base` 映射中为 `modified` 列出的某个路径提供条目时,`files_not_at_base` 只检查 `base` 中已有的条目,因此被遗漏的已修改文件会通过验证,补丁随后覆盖本地修改。
请在应用补丁之前,要求每个已修改路径都必须有对应的 base 哈希。
另见 `src-tauri/src/commands/update.rs:355`。Original comment in English
🟠 High · Local edits bypass validation
When changes.json omits a path listed in modified from its base map, files_not_at_base checks only entries present in base, so the omitted modified file passes validation and the patch overwrites the local edit.
Require a base hash for every modified path before applying the patch.
Also at src-tauri/src/commands/update.rs:355.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 353-354:
**Local edits bypass validation**
When `changes.json` omits a path listed in `modified` from its `base` map, `files_not_at_base` checks only entries present in `base`, so the omitted modified file passes validation and the patch overwrites the local edit.
Require a base hash for every modified path before applying the patch.
Also at `src-tauri/src/commands/update.rs:355`.| &old_dir.join(format!("incremental-{}", stamp)), | ||
| )?; |
There was a problem hiding this comment.
🟠 高 · 并发清理会删除回滚文件
当下载或另一次更新将文件移入旧文件目录时,增量更新可能已在那里暂存了备份。增量更新会将回滚文件存放在 cache/old/incremental-<timestamp> 下,而 move_to_old_folder 在移动任何文件之前会删除整个 cache/old 目录。如果下载或另一次更新在第一次更新执行期间调用该辅助函数,第一次更新之后若失败便无法恢复文件,导致安装目录内容混杂。
请将事务备份存放在通用旧文件清理操作不会删除的目录中,或者让清理操作与活动更新串行执行。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 264-265 行:
**并发清理会删除回滚文件**
当下载或另一次更新将文件移入旧文件目录时,增量更新可能已在那里暂存了备份。增量更新会将回滚文件存放在 `cache/old/incremental-<timestamp>` 下,而 `move_to_old_folder` 在移动任何文件之前会删除整个 `cache/old` 目录。如果下载或另一次更新在第一次更新执行期间调用该辅助函数,第一次更新之后若失败便无法恢复文件,导致安装目录内容混杂。
请将事务备份存放在通用旧文件清理操作不会删除的目录中,或者让清理操作与活动更新串行执行。Original comment in English
🟠 High · Concurrent cleanup removes rollback files
When a download or another update moves a file to old while an incremental update has staged backups there, an incremental update stores its rollback files under cache/old/incremental-<timestamp>, while move_to_old_folder removes the entire cache/old tree before moving any file. If a download or another update calls that helper while the first update is applying, the first update's later failure cannot restore its files, leaving the installation mixed.
Keep transaction backups outside the directory that general old-file cleanup removes, or serialize cleanup with active updates.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 264-265:
**Concurrent cleanup removes rollback files**
When a download or another update moves a file to old while an incremental update has staged backups there, an incremental update stores its rollback files under `cache/old/incremental-<timestamp>`, while `move_to_old_folder` removes the entire `cache/old` tree before moving any file. If a download or another update calls that helper while the first update is applying, the first update's later failure cannot restore its files, leaving the installation mixed.
Keep transaction backups outside the directory that general old-file cleanup removes, or serialize cleanup with active updates.| &deleted_files, | ||
| &old_dir.join(format!("incremental-{}", stamp)), |
There was a problem hiding this comment.
🟡 中 · 更新成功后备份会丢失
当增量更新成功且下载的 ZIP 被移入旧文件目录时,move_to_old_folder 会清空 cache/old,其中也包括刚创建的增量备份,因此成功更新后没有可用于恢复的已保存文件。
请将增量备份存放在 move_to_old_folder 清理范围之外,或者在该辅助函数清理旧文件夹时保留这些备份。
另见 src-tauri/src/commands/update.rs:265。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 263-264 行:
**更新成功后备份会丢失**
当增量更新成功且下载的 ZIP 被移入旧文件目录时,`move_to_old_folder` 会清空 `cache/old`,其中也包括刚创建的增量备份,因此成功更新后没有可用于恢复的已保存文件。
请将增量备份存放在 `move_to_old_folder` 清理范围之外,或者在该辅助函数清理旧文件夹时保留这些备份。
另见 `src-tauri/src/commands/update.rs:265`。Original comment in English
🟡 Medium · Successful updates lose backups
When an incremental update succeeds and the downloaded ZIP is moved to the old folder, move_to_old_folder clears cache/old, including the incremental backups just created there, so the successful update leaves no saved files available for restoration.
Store incremental backups outside the directory cleared by move_to_old_folder, or preserve them when that helper clears the old folder.
Also at src-tauri/src/commands/update.rs:265.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 263-264:
**Successful updates lose backups**
When an incremental update succeeds and the downloaded ZIP is moved to the old folder, `move_to_old_folder` clears `cache/old`, including the incremental backups just created there, so the successful update leaves no saved files available for restoration.
Store incremental backups outside the directory cleared by `move_to_old_folder`, or preserve them when that helper clears the old folder.
Also at `src-tauri/src/commands/update.rs:265`.| const changesJson = await invoke<ChangesJson | null>('check_changes_json', { | ||
| extractDir, | ||
| }); | ||
| incremental = changesJson !== null; |
There was a problem hiding this comment.
🟡 中 · 损坏的补丁看起来仍可使用
当解压后的增量归档包含无法读取或解析的 changes.json 时,check_changes_json 会在设置 incremental 之前失败,因此 catch 会将补丁视为完整安装包并调用 fallback_update,把其中不完整的内容呈现为可用的版本文件夹。
请在解析之前检测 changes.json 是否存在,并阻止格式错误的增量包走完整安装包的回退流程。
另见 src/services/updateService.ts:1090-1094。
给 AI 智能体的提示
在 `src/services/updateService.ts` 第 1041 行:
**损坏的补丁看起来仍可使用**
当解压后的增量归档包含无法读取或解析的 `changes.json` 时,`check_changes_json` 会在设置 `incremental` 之前失败,因此 catch 会将补丁视为完整安装包并调用 `fallback_update`,把其中不完整的内容呈现为可用的版本文件夹。
请在解析之前检测 `changes.json` 是否存在,并阻止格式错误的增量包走完整安装包的回退流程。
另见 `src/services/updateService.ts:1090-1094`。Original comment in English
🟡 Medium · Broken patches appear usable
When an extracted incremental archive contains a changes.json that cannot be read or parsed, check_changes_json fails before incremental is set, so the catch treats the patch as a full package and calls fallback_update, presenting its partial contents as a usable version folder.
Detect the presence of changes.json before parsing it and prevent malformed incremental packages from taking the full-package fallback.
Also at src/services/updateService.ts:1090-1094.
Prompt for AI agents
In `src/services/updateService.ts` at line 1041:
**Broken patches appear usable**
When an extracted incremental archive contains a `changes.json` that cannot be read or parsed, `check_changes_json` fails before `incremental` is set, so the catch treats the patch as a full package and calls `fallback_update`, presenting its partial contents as a usable version folder.
Detect the presence of `changes.json` before parsing it and prevent malformed incremental packages from taking the full-package fallback.
Also at `src/services/updateService.ts:1090-1094`.| } catch (error) { | ||
| log.error('更新安装失败:', error); | ||
|
|
||
| if (incremental) { |
There was a problem hiding this comment.
🟡 中 · 已提交的更新却报告失败
当增量应用成功,但清理解压目录或移动 ZIP 失败时,catch 仍会看到 incremental 为 true,并将应用完成后的错误当作应用失败处理。因此,即使目标目录已经包含新版本,调用方仍会收到安装错误。
请将增量应用错误处理器的作用范围限制在应用操作本身,并在应用成功后分别处理清理或 ZIP 移动失败。
给 AI 智能体的提示
在 `src/services/updateService.ts` 第 1090 行:
**已提交的更新却报告失败**
当增量应用成功,但清理解压目录或移动 ZIP 失败时,catch 仍会看到 `incremental` 为 true,并将应用完成后的错误当作应用失败处理。因此,即使目标目录已经包含新版本,调用方仍会收到安装错误。
请将增量应用错误处理器的作用范围限制在应用操作本身,并在应用成功后分别处理清理或 ZIP 移动失败。Original comment in English
🟡 Medium · Committed updates report failure
When incremental application succeeds but cleanup of the extracted directory or moving the ZIP fails, the catch still sees incremental as true and treats the post-apply error as an apply failure, so the caller receives an installation error even though the target already contains the new version.
Limit the incremental-apply error handler to the apply operation and handle cleanup or ZIP-move failures separately after a successful apply.
Prompt for AI agents
In `src/services/updateService.ts` at line 1090:
**Committed updates report failure**
When incremental application succeeds but cleanup of the extracted directory or moving the ZIP fails, the catch still sees `incremental` as true and treats the post-apply error as an apply failure, so the caller receives an installation error even though the target already contains the new version.
Limit the incremental-apply error handler to the apply operation and handle cleanup or ZIP-move failures separately after a successful apply.| if path.is_dir() { | ||
| stack.push(path); |
There was a problem hiding this comment.
🟡 中 · 符号链接会使提取目录扫描越界
当增量归档包含目录符号链接时,relative_files 会跟随目录符号链接,可能扫描提取目录之外的内容;符号链接循环也可能导致扫描失败或耗尽资源。
扫描已提取文件时,请勿进入符号链接目录。
给 AI 智能体的提示
在 `src-tauri/src/commands/update.rs` 第 328-329 行:
**符号链接会使提取目录扫描越界**
当增量归档包含目录符号链接时,`relative_files` 会跟随目录符号链接,可能扫描提取目录之外的内容;符号链接循环也可能导致扫描失败或耗尽资源。
扫描已提取文件时,请勿进入符号链接目录。Original comment in English
🟡 Medium · Symlinks escape extraction scan
When an incremental archive contains a directory symlink, relative_files follows directory symlinks and can scan outside the extraction tree; a symlink cycle can also make the scan fail or exhaust resources.
Do not descend into symlink directories when scanning extracted files.
Prompt for AI agents
In `src-tauri/src/commands/update.rs` at lines 328-329:
**Symlinks escape extraction scan**
When an incremental archive contains a directory symlink, `relative_files` follows directory symlinks and can scan outside the extraction tree; a symlink cycle can also make the scan fail or exhaust resources.
Do not descend into symlink directories when scanning extracted files.- changes.json 中含 ..、磁碟機代號或根路徑的條目一律拒絕 - 提供 base 時,modified 中每個檔案都要有對應雜湊 - deleted 檔案無法移走時更新失敗並還原,不再留下舊檔 - 新檔案在複製前就記錄,複製中途失敗也能刪除 - 備份改放 cache/incremental-backup,不受 move_to_old_folder 清理影響 - 掃描解壓目錄時不跟隨符號連結 - changes.json 無法解析時仍視為增量包,不走兜底資料夾 - 檔案已套用後,清理暫存檔失敗不再回報為安裝失敗 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
已在 9268f3a 處理: 已修正
新增 2 個測試(越界路徑、缺少 base 雜湊),共 5 個。 這個 PR 先不處理
|
关联 #138、#208。
#138 关闭时提到的顾虑之一,是没有校验时增量更新可能弄坏用户的资源。这个 PR 在不改变现有增量包格式的前提下,补上安装端的保护:
cache/old/incremental-<时间戳>,任一步失败就全部放回,安装目录保持旧版本,不会变成新旧混合。changes.json可带"base": { "<相对路径>": "<旧版本 SHA-256>" }(modified 的文件)。本地文件与旧版本不一致或缺失(用户手动改过、上次更新中断)时,不做任何改动并提示下载完整包。没有 base 的包行为与现在相同。v版本号兜底文件夹(增量包只含变动文件,该文件夹无法运行)。我们在自己的项目(基于 MXU 的分支)上线了同样的机制:每个版本额外发布「上一版 → 本版」的补丁和带 base 的清单,客户端版本对不上或 base 不符就改下载完整包。实际数据:补丁 5.3 MB,完整包 164 MB。
base 由生成增量包的一方(Mirror酱或项目 CI)填写;若方向可行,也可以再讨论在 PI 协议层定义这个字段。
测试:新增 3 个单元测试(正常应用、中途失败还原、base 不符时不改动任何文件)。
🤖 Generated with Claude Code
Sourcery 总结
通过验证基础版本、校验已复制的文件,并在发生错误时回滚所有更改,确保增量更新安全失败。
新功能:
错误修复:
测试:
Original summary in English
Sourcery 总结
增强增量更新的安全性:当基础版本不匹配或更新过程失败时,安全地拒绝更新并恢复到原版本。
新功能:
错误修复:
改进:
测试:
Original summary in English
Summary by Sourcery
增强增量更新的安全性,使基础版本不匹配或更新过程失败时安全拒绝并恢复到原版本。
New Features:
Bug Fixes:
Enhancements:
Tests: