Skip to content

[Bug]: 导入备份/快照时无条件覆盖用户的真实规则文件并删除版本历史(绕过 rules 冲突保护) #210

Description

@bloatfan

Summary (EN): importRuleBackupRecords unconditionally overwrites the user's real rule file (targetPath, e.g. ~/.claude/CLAUDE.md) during backup/snapshot import, without comparing the target's content hash and without going through the existing conflict-resolution flow — and it deletes that rule's local version history in the same loop. Both recovery paths are destroyed at once, silently. Confirmed present in v0.5.8 and in current main (0.6.0-beta.1).

Area

Sync / backup / restore

Platform

macOS Apple Silicon (arm64, macOS 26.4.1)

Version

0.5.8(桌面版,stable 通道)。代码引用同时对照 tag v0.5.8 与当前 main(0.6.0-beta.1),两者的覆盖行为一致。

What happened?

在一次包含 rules 的备份/快照导入之后,PromptHub 把托管副本里的旧内容写回了我本机的全局规则文件,覆盖掉我在 PromptHub 之外手写的内容,并且把该规则的本地版本历史整个删掉了。

观察到的具体表现:

  • claude-globaltargetPath~/.claude/CLAUDE.md)被替换成一份约三个月前的旧内容,我在那之后手写的修改全部消失。
  • 六个内置全局规则(claude / codex / gemini / amp / windsurf / opencode)在同一秒内全部被写入,_rule.jsonupdatedAt 均落在同一秒。
  • 其中三条备份记录的 record.content 是空串,于是对应的三个目标文件被写成 0 字节
  • data/rules/.versions/<ruleId>/ 目录被清空重建,原有历史快照消失,因此「历史快照预览 / 恢复到草稿」也无法找回内容。

因为覆盖是完全静默的(没有提示、没有冲突标记、日志里也没有记录),而规则文件平时不会主动去看,这类丢失很难被及时发现。我这边是在被反复覆盖多次、间隔两个多月之后才定位到原因。

What did you expect?

导入不应在未经确认的情况下覆盖与托管副本内容不一致的 target 文件。期望行为:

  1. 导入前对每条 record 比对 target 的内容哈希 —— inspectRuleSyncState() 已经具备这个能力,只是这条路径没有调用它。
  2. 内容不一致时标记为 out-of-sync / conflict,交给已有的 resolveRuleConflict() 让用户显式选择保留哪一侧,而不是直接覆盖。
  3. 版本历史不应被无条件删除 —— 在 target 已被覆盖的情况下,它恰好是用户唯一的补救手段。
  4. record.content === "" 的记录建议跳过或单独确认,避免把用户的目标文件清零。

CHANGELOG 0.5.5 已经为 Skill 更新引入了「本地修改冲突保护 / Local Edit Conflict Protection」(比较安装时哈希 / 当前本地哈希 / 最新远端哈希,冲突需用户显式选择覆盖)。rules 的导入路径目前缺少等价保护,建议与之对齐。

Steps to reproduce

  1. 让 PromptHub 接管任一全局规则(例如 Claude Code 的 ~/.claude/CLAUDE.md),此时托管副本与目标文件一致。
  2. 在 PromptHub 之外直接编辑该目标文件(编辑器、脚本、其它工具皆可),使其与托管副本不一致。此时 App 内该规则应显示为 out-of-sync
  3. 触发一次包含 rules 的备份/快照导入(Desktop 的备份恢复,或 CLI 的 workspace / sync restore)。
  4. 观察:目标文件被替换为 record 中的内容,第 2 步的本地修改丢失;data/rules/.versions/<ruleId>/ 被清空重建。

相关代码 / Code references

packages/core/src/rules-workspace.ts @ tag v0.5.8importRuleBackupRecords()(L1075 起),循环体内 L1098-L1102:

const meta = await resolveRuleMeta(record.id);
await writeManagedRule(meta, record.content);
const restoredSyncStatus = await writeTargetRule(meta, record.content);          // L1100:无条件覆盖 target
await fsp.rm(getRuleVersionsDir(record.id), { recursive: true, force: true });   // 版本历史整体删除

writeTargetRule()(L469):

try {
  await fsp.mkdir(path.dirname(meta.targetPath), { recursive: true });
  await fsp.writeFile(meta.targetPath, content, "utf-8");
  return "synced";
} catch {
  return "sync-error";
}

几点补充:

  • 这段既没有调用 inspectRuleSyncState(),也没有走 resolveRuleConflict() —— App 内已有的冲突保护机制在这条路径上被整体绕过。
  • fsp.writeFile 会跟随符号链接写穿。若 targetPath 是 symlink(用 dotfiles 管理工具托管 ~/.claude/CLAUDE.md 是常见做法),被改写的是链接指向的真实文件,用户更不容易察觉。
  • 当前 main(0.6.0-beta.1)中对应位置为 rules-workspace.ts:1471fsp.rm 已换成 replaceRuleVersions(),但无条件覆盖 target 的行为未变writeTargetRule 的三个调用点中这一处仍然没有任何冲突检查。

触发链路(Desktop):

apps/desktop/src/renderer/services/database-backup.ts
  → window.api.rules.importRecords(records, { replace })
  → apps/desktop/src/main/ipc/rules.ipc.ts  (IPC_CHANNELS.RULES_IMPORT_RECORDS)
  → importRuleBackupRecords(records, { replace })

CLI 侧:packages/core/src/cli/workspace-sync.tsrestoreCliWorkspaceSnapshot(){ replace: true } 调用同一函数。

Logs, screenshots, or extra context

- data/rules/global/<platform>/_rule.json:六条规则 updatedAt 落在同一秒
- data/rules/.versions/<ruleId>/:仅剩重建后的 0001.md,index.json mtime 与导入时刻一致
- data/.trash/<ISO>/:同一时刻新增多个条目(options.replace → removeMissingProjectRules)
- logs/startup.log:不记录这次导入,仅有 startup:* 与 recovery:candidates_detected 等启动阶段事件

排查时最大的困难是没有任何日志线索:目标文件被改写、版本历史被删除,都不产生日志。建议给 rules 导入加一条结构化日志(写了哪些 targetPath、各自 syncStatus、是否覆盖了 out-of-sync 的内容、跳过了哪些空内容 record),这对定位这类静默数据丢失会很有帮助。

Before submitting

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions