dsh-config-manager 0.1.80 → 0.1.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/cli/actions.d.ts +5 -0
  4. package/lib/cli/actions.js +5 -0
  5. package/lib/cli/index.js +33 -17
  6. package/lib/cli/restore-resume.d.ts +25 -0
  7. package/lib/cli/restore-resume.js +49 -0
  8. package/lib/client.d.ts +6 -0
  9. package/lib/client.js +521 -519
  10. package/lib/core/analyzer.d.ts +3 -41
  11. package/lib/core/analyzer.js +9 -292
  12. package/lib/core/import-planning.d.ts +110 -0
  13. package/lib/core/import-planning.js +305 -0
  14. package/lib/core/journal.d.ts +12 -0
  15. package/lib/core/journal.js +12 -0
  16. package/lib/core/messages.d.ts +17 -0
  17. package/lib/core/messages.js +37 -0
  18. package/lib/core/model-tools.js +17 -5
  19. package/lib/core/phase3-host.d.ts +21 -0
  20. package/lib/core/phase3-host.js +63 -14
  21. package/lib/core/plugin-cli.d.ts +24 -2
  22. package/lib/core/plugin-cli.js +36 -3
  23. package/lib/core/recovery-orchestrator.d.ts +17 -1
  24. package/lib/core/recovery-orchestrator.js +39 -29
  25. package/lib/core/restore-crash-child.d.ts +1 -0
  26. package/lib/core/restore-crash-child.js +77 -0
  27. package/lib/core/restore-resume.d.ts +161 -0
  28. package/lib/core/restore-resume.js +274 -0
  29. package/lib/core/restore-wal.d.ts +88 -0
  30. package/lib/core/restore-wal.js +164 -0
  31. package/lib/core/restore.d.ts +39 -1
  32. package/lib/core/restore.js +229 -13
  33. package/lib/core/rollback-ignore-rules.d.ts +14 -0
  34. package/lib/core/rollback-ignore-rules.js +54 -1
  35. package/lib/core/rollback-point.d.ts +3 -1
  36. package/lib/core/rollback-point.js +10 -1
  37. package/lib/index.d.ts +6 -252
  38. package/lib/index.js +17 -561
  39. package/lib/market/git-file-writer.d.ts +12 -3
  40. package/lib/market/git-file-writer.js +42 -6
  41. package/lib/market/messages-market.d.ts +1 -0
  42. package/lib/market/messages-market.js +2 -0
  43. package/lib/market/reader.d.ts +14 -2
  44. package/lib/market/reader.js +57 -18
  45. package/lib/routes/consult.js +1 -1
  46. package/lib/routes/import.js +1 -1
  47. package/lib/routes/market.js +1 -1
  48. package/lib/routes/me.js +1 -1
  49. package/lib/routes/prefs.js +1 -2
  50. package/lib/routes/profiles.js +1 -1
  51. package/lib/routes/recovery.js +1 -1
  52. package/lib/routes/route-support.d.ts +275 -0
  53. package/lib/routes/route-support.js +637 -0
  54. package/lib/routes/snapshots.js +40 -8
  55. package/lib/routes/sync.js +17 -1
  56. package/lib/sync/autosync-scheduler.d.ts +8 -3
  57. package/lib/sync/autosync-scheduler.js +22 -9
  58. package/lib/sync/gist/gist-transport.d.ts +44 -2
  59. package/lib/sync/gist/gist-transport.js +133 -39
  60. package/lib/sync/gist/messages.d.ts +1 -0
  61. package/lib/sync/gist/messages.js +3 -0
  62. package/lib/sync/git/git-transport.d.ts +33 -0
  63. package/lib/sync/git/git-transport.js +111 -13
  64. package/lib/sync/s3/oss4-verify.d.ts +47 -0
  65. package/lib/sync/s3/oss4-verify.js +156 -0
  66. package/lib/sync/s3/s3-providers.d.ts +10 -0
  67. package/lib/sync/s3/s3-providers.js +29 -6
  68. package/lib/sync/s3/s3-transport.js +6 -1
  69. package/lib/sync/s3/sigv4.d.ts +23 -2
  70. package/lib/sync/s3/sigv4.js +19 -8
  71. package/lib/sync/sync-engine.d.ts +13 -0
  72. package/lib/sync/sync-engine.js +9 -1
  73. package/lib/sync/sync-session.d.ts +5 -0
  74. package/lib/utils/git-fast-forward.d.ts +18 -0
  75. package/lib/utils/git-fast-forward.js +28 -0
  76. package/lib/utils/workdir-lock.d.ts +58 -0
  77. package/lib/utils/workdir-lock.js +147 -0
  78. package/package.json +2 -1
  79. package/src/cli/actions.ts +6 -0
  80. package/src/cli/index-guards.test.ts +62 -0
  81. package/src/cli/index.ts +30 -20
  82. package/src/cli/restore-resume.test.ts +120 -0
  83. package/src/cli/restore-resume.ts +76 -0
  84. package/src/client/CLIENT_DEPENDENCIES.md +8 -29
  85. package/src/client/sync/merge-view.ts +2 -0
  86. package/src/core/analyzer.ts +16 -340
  87. package/src/core/import-planning.ts +356 -0
  88. package/src/core/journal.ts +13 -0
  89. package/src/core/messages.ts +37 -0
  90. package/src/core/model-tools.ts +19 -5
  91. package/src/core/phase3-host.ts +73 -14
  92. package/src/core/plugin-cli.fs.test.ts +72 -2
  93. package/src/core/plugin-cli.ts +35 -3
  94. package/src/core/recovery-orchestrator.ts +41 -19
  95. package/src/core/restore-crash-child.ts +85 -0
  96. package/src/core/restore-crash-injection.test.ts +329 -0
  97. package/src/core/restore-resume.test.ts +376 -0
  98. package/src/core/restore-resume.ts +414 -0
  99. package/src/core/restore-target-symlink.test.ts +232 -0
  100. package/src/core/restore-wal.test.ts +170 -0
  101. package/src/core/restore-wal.ts +222 -0
  102. package/src/core/restore.ts +251 -13
  103. package/src/core/rollback-ignore-rules.ts +53 -1
  104. package/src/core/rollback-point.ts +19 -2
  105. package/src/core/rollback-sensitive-exclusion.test.ts +226 -0
  106. package/src/index.facade.test.ts +49 -0
  107. package/src/index.ts +23 -647
  108. package/src/market/git-file-writer.test.ts +39 -3
  109. package/src/market/git-file-writer.ts +43 -7
  110. package/src/market/market.test.ts +31 -3
  111. package/src/market/messages-market.ts +2 -0
  112. package/src/market/reader.ts +55 -18
  113. package/src/routes/consult.ts +1 -1
  114. package/src/routes/import.ts +1 -1
  115. package/src/routes/market.ts +1 -1
  116. package/src/routes/me.ts +1 -1
  117. package/src/routes/prefs.ts +3 -2
  118. package/src/routes/profiles.ts +1 -1
  119. package/src/routes/recovery.ts +1 -1
  120. package/src/routes/route-support.ts +735 -0
  121. package/src/routes/snapshots.ts +47 -7
  122. package/src/routes/sync.ts +17 -1
  123. package/src/security/secret-scanner.test.ts +1 -1
  124. package/src/sync/autosync-scheduler.test.ts +62 -0
  125. package/src/sync/autosync-scheduler.ts +21 -8
  126. package/src/sync/gist/gist-conditional-write.test.ts +34 -15
  127. package/src/sync/gist/gist-delete-concurrency.test.ts +244 -0
  128. package/src/sync/gist/gist-overwrite-semantics.test.ts +258 -0
  129. package/src/sync/gist/gist-transport.ts +154 -44
  130. package/src/sync/gist/messages.ts +3 -0
  131. package/src/sync/git/git-transport.test.ts +68 -6
  132. package/src/sync/git/git-transport.ts +117 -14
  133. package/src/sync/s3/oss4-verify.ts +201 -0
  134. package/src/sync/s3/s3-providers.ts +32 -6
  135. package/src/sync/s3/s3-transport.oss4-server.test.ts +117 -0
  136. package/src/sync/s3/s3-transport.test.ts +44 -1
  137. package/src/sync/s3/s3-transport.ts +8 -1
  138. package/src/sync/s3/sigv4.test.ts +97 -0
  139. package/src/sync/s3/sigv4.ts +38 -9
  140. package/src/sync/sync-engine.test.ts +64 -0
  141. package/src/sync/sync-engine.ts +22 -1
  142. package/src/sync/sync-session.ts +5 -0
  143. package/src/utils/git-fast-forward.test.ts +44 -0
  144. package/src/utils/git-fast-forward.ts +31 -0
  145. package/src/utils/workdir-lock.test.ts +111 -0
  146. package/src/utils/workdir-lock.ts +162 -0
@@ -9,7 +9,7 @@
9
9
  * profile 层 cordis.patch.yml)的 blob 写回 $DSH_HOME;快照时不存在、现已出现
10
10
  * 的文件删除(restore = 还原到快照那一刻的状态,含移除导入期间新增激活行);
11
11
  * 2. pre-restore 双保险:任何覆盖/删除前,把当前文件复制到
12
- * <snapshotDir>/pre-restore/(可人工反悔);
12
+ * <snapshotDir>/pre-restore/<attemptDir>/(每次执行一个目录,可人工反悔,重跑不覆盖);
13
13
  * 3. 插件撤销:beforePlugins(导入前基线)与当前已装对比 → 导入期间新增的插件
14
14
  * 走官方 `dsh plugin --profile <p> remove <pkg>`(runDshPlugin 通道,零新子进程代码);
15
15
  * 4. file 类条目补偿:skills/agentPresets/agentInstructions/pluginFiles/sessions 原文件 blob 写回 /
@@ -21,6 +21,7 @@
21
21
  * 旧快照(无 beforePlugins / hostFileBackups)兼容:缺基线的部分只提示不动作。
22
22
  */
23
23
  import { engineMsg } from './messages-core.js';
24
+ import fssync from 'node:fs';
24
25
  import fs from 'node:fs/promises';
25
26
  import path from 'node:path';
26
27
  import { normalizeDshVersion, resolveFileTarget, verifySnapshot } from './backup.js';
@@ -28,6 +29,7 @@ import { isPatchLayerRel } from './patch-layers.js';
28
29
  import { expectedSessionRefs, sweepGhostSessions } from './ghost-sweep.js';
29
30
  import { zhMsg } from './messages.js';
30
31
  import { classifyDshPluginFailure, readInstalled, readInstalledVersion, resolveProfileDir, runDshPlugin, } from './plugin-cli.js';
32
+ import { collectRestoreWalEntries, createRestoreWal, restoreActionStepId, restoreWalHandlesFromSteps, } from './restore-wal.js';
31
33
  import { parseJsonSafe } from '../utils/json.js';
32
34
  import { atomicCopyFile, atomicWriteFile } from '../utils/atomic-write.js';
33
35
  /* ------------------------------------------------------------ 工具 */
@@ -60,6 +62,19 @@ export function homeAbs(homeDir, relPath, msg) {
60
62
  throw new Error(msg('restore.pathEscape', { path: relPath }));
61
63
  return abs;
62
64
  }
65
+ /**
66
+ * home 相对目标 → 绝对路径(越界/非法给 null 而不是抛错)。
67
+ * 供逐动作 WAL 与续跑判定用:这两处都只需要「能不能拿到绝对路径」这一个事实,
68
+ * 判定失败由调用方决定是跳过该动作还是拒绝整笔。
69
+ */
70
+ export function resolveRestoreTargetAbs(homeDir, target) {
71
+ try {
72
+ return homeAbs(homeDir, target, zhMsg);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
63
78
  /** 快照目录内 blob 绝对路径(防 snapshot.json 里伪造 ../ 越界读) */
64
79
  export function blobAbs(snapshotDir, blobPath, msg) {
65
80
  const abs = path.resolve(snapshotDir, blobPath);
@@ -73,6 +88,114 @@ function toRelPath(homeDir, abs) {
73
88
  return '';
74
89
  return path.relative(homeDir, abs).split(path.sep).join('/');
75
90
  }
91
+ /* ------------------------- 目标侧真实路径围栏(junction / symlink 防写穿,D4) */
92
+ /**
93
+ * 目标侧路径安全:字符串前缀围栏 `isWithinHome` 挡不住两类逃逸 ——
94
+ * 1. `$DSH_HOME` 内某个**目录**本身是链接(Windows junction / POSIX 目录 symlink):
95
+ * 字符串前缀成立,真实落点却在 home 之外;
96
+ * 2. **目标文件本身是 symlink**:`atomicCopyFile` 缺省 `symlink:'follow'`
97
+ * (src/utils/atomic-write.ts:305)解析真实路径后改写 home 外的文件,而链接结构不变(肉眼无痕)。
98
+ * 因此计划期对每个「会被写入 / 删除」的目标做一次 realpath 判定;任一目标不安全 ⇒
99
+ * **整笔计划作废**(`planRestore` 抛错;`restore()` 是先规划后执行 ⇒ 零写入),
100
+ * 绝不逐项降级为 skip —— 半套恢复比不恢复更难收拾。
101
+ *
102
+ * 判据三条:
103
+ * a. 目标存在且 `lstat().isSymbolicLink()` → 拒绝(含指向 home 内的链接:写它等于改写另一份文件);
104
+ * b. 从 `dirname(目标)` 向上找第一个**存在**的祖先,`realpathSync.native` 解析;解析失败 → 拒绝;
105
+ * c. 真实落点必须仍在 `realpath(home)` 之内(`path.relative` 判定,Windows 折大小写比较)。
106
+ *
107
+ * 为什么 home 侧也 realpath:macOS `/var → /private/var`、Windows 8.3 短名 / 目录 junction 下
108
+ * 恢复自己的 home 是**正常**场景 —— 只解析目标侧会把正常恢复误判成越界。
109
+ *
110
+ * 缓存:按「实际解析过的祖先目录」缓存真实路径(同一父目录只解析一次),
111
+ * 不缓存不存在的中间层(它们的落点 = 存在祖先的真实路径 + 剩余段推导)。
112
+ */
113
+ /** realpath,失败(不存在 / 无权限 / 不可解析)返回 null */
114
+ function realpathOrNull(p) {
115
+ try {
116
+ return fssync.realpathSync.native(p);
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ /** 比较键:Windows 文件系统大小写不敏感 */
123
+ function foldPathKey(p) {
124
+ return process.platform === 'win32' ? p.toLowerCase() : p;
125
+ }
126
+ /** child 是否等于 root 或位于 root 之内(两侧都应是已解析的真实路径) */
127
+ function isRealInside(root, child) {
128
+ const rel = path.relative(foldPathKey(root), foldPathKey(child));
129
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
130
+ }
131
+ /**
132
+ * 生成「目标安全性」判定函数;返回 null = 安全,否则返回**机器码**(只作日志/诊断,
133
+ * 用户可见文案仍走既有 `restore.pathEscape`,不新造自然语言)。
134
+ */
135
+ function createTargetSafetyChecker(homeDir) {
136
+ const homeResolved = path.resolve(homeDir);
137
+ const rootReal = realpathOrNull(homeResolved) ?? homeResolved;
138
+ const ancestorCache = new Map();
139
+ /** 父目录的真实落点:向上找第一个存在的祖先,再把不存在的剩余段拼回去 */
140
+ const projectedParentReal = (parent) => {
141
+ const parts = [];
142
+ let cur = parent;
143
+ for (;;) {
144
+ const cached = ancestorCache.get(foldPathKey(cur));
145
+ if (cached !== undefined)
146
+ return parts.length === 0 ? cached : path.join(cached, ...parts.reverse());
147
+ const real = realpathOrNull(cur);
148
+ if (real !== null) {
149
+ ancestorCache.set(foldPathKey(cur), real);
150
+ return parts.length === 0 ? real : path.join(real, ...parts.reverse());
151
+ }
152
+ const up = path.dirname(cur);
153
+ if (up === cur)
154
+ return null; // 上溯到根仍不可解析 ⇒ 保守拒绝
155
+ parts.push(path.basename(cur));
156
+ cur = up;
157
+ }
158
+ };
159
+ return (absTarget) => {
160
+ try {
161
+ if (fssync.lstatSync(absTarget).isSymbolicLink())
162
+ return 'target-symlink';
163
+ }
164
+ catch (err) {
165
+ if (err.code !== 'ENOENT')
166
+ return 'target-unreadable';
167
+ }
168
+ const parentReal = projectedParentReal(path.dirname(absTarget));
169
+ if (parentReal === null)
170
+ return 'parent-unresolvable';
171
+ if (!isRealInside(rootReal, parentReal))
172
+ return 'parent-escape';
173
+ return null;
174
+ };
175
+ }
176
+ /** 计划里会真正落盘 / 删文件的动作(skip / pluginRemove / credentialHint 不碰目标路径) */
177
+ const WRITE_TARGET_KINDS = new Set([
178
+ 'hostFileRestore', 'hostFileRemove', 'fileRestore', 'fileRemove',
179
+ ]);
180
+ /**
181
+ * 计划期目标侧总闸:任一目标不安全就抛错(整笔拒绝)。
182
+ * 字符串越界的目标在计划期已降级为 `skip`(不会走到这里),此处只补真实路径这一层。
183
+ */
184
+ function assertPlanTargetsSafe(actions, homeDir, msg) {
185
+ const check = createTargetSafetyChecker(homeDir);
186
+ for (const a of actions) {
187
+ if (a.target === undefined || !WRITE_TARGET_KINDS.has(a.kind))
188
+ continue;
189
+ const abs = path.resolve(homeDir, a.target);
190
+ if (!isWithinHome(homeDir, abs))
191
+ continue;
192
+ const unsafe = check(abs);
193
+ if (unsafe !== null) {
194
+ // 复用既有「路径越界」语义与错误码:planRestore 顶层抛出 ⇒ restore() 一个字节都没写。
195
+ throw new Error(msg('restore.pathEscape', { path: a.target }));
196
+ }
197
+ }
198
+ }
76
199
  /** file 类条目的目标绝对路径(复用 backup.ts 的基准目录规则;仅读 homeDir) */
77
200
  function resolveFileAbs(homeDir, adapter, ref) {
78
201
  return resolveFileTarget({ homeDir }, adapter, ref);
@@ -89,7 +212,37 @@ async function resolveSettingsRelPath(homeDir, settingsPath) {
89
212
  }
90
213
  return undefined;
91
214
  }
92
- /** 把当前文件复制到 <snapshotDir>/pre-restore/(双保险,命名含序号防碰撞) */
215
+ /** pre-restore attempt 目录名:本地时间 yyyyMMdd-HHmmss-SSS */
216
+ function preRestoreStamp(now = new Date()) {
217
+ const p2 = (n) => String(n).padStart(2, '0');
218
+ return `${String(now.getFullYear())}${p2(now.getMonth() + 1)}${p2(now.getDate())}-`
219
+ + `${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}-`
220
+ + String(now.getMilliseconds()).padStart(3, '0');
221
+ }
222
+ /**
223
+ * 新建本次执行的 pre-restore 目录(D5):`<snapshotDir>/pre-restore/<attemptDir>/`。
224
+ *
225
+ * 为什么必须分目录:备份名原来是「seq + relPath」、seq 每次都从 1 重数 ⇒ 崩溃后重跑会把
226
+ * 第一次的「恢复前原文」覆盖成「第一次恢复后的内容」= 反悔副本静默丢失。
227
+ * 同名(同一毫秒内重复调用 / 时钟回拨)时追加 `-1`/`-2`…:靠 `mkdir` 的 EEXIST 判定,
228
+ * **不 pre-stat**(先查后建在并发下会撞车)。
229
+ */
230
+ export async function createPreRestoreAttemptDir(preRoot) {
231
+ await fs.mkdir(preRoot, { recursive: true });
232
+ const stamp = preRestoreStamp();
233
+ for (let i = 0;; i += 1) {
234
+ const candidate = path.join(preRoot, i === 0 ? stamp : `${stamp}-${i}`);
235
+ try {
236
+ await fs.mkdir(candidate);
237
+ return candidate;
238
+ }
239
+ catch (err) {
240
+ if (err.code !== 'EEXIST')
241
+ throw err;
242
+ }
243
+ }
244
+ }
245
+ /** 把当前文件复制到 <snapshotDir>/pre-restore/<attemptDir>/(双保险,命名含序号防碰撞) */
93
246
  async function copyToPreRestore(preDir, absPath, label, seq) {
94
247
  const safe = label.replace(/[\\/:*?"<>|]/g, '_');
95
248
  await fs.mkdir(preDir, { recursive: true });
@@ -476,6 +629,9 @@ export async function planRestore(opts) {
476
629
  detail: msg('restore.versionMismatchDetail', { snapshot: snapshotDshVersion, current: currentDshVersion }),
477
630
  });
478
631
  }
632
+ // D4:目标侧真实路径总闸(junction / 目录 symlink / 目标自身是 symlink)——
633
+ // 任一目标不安全 ⇒ 整笔拒绝(抛错,零写入),绝不逐项跳过。
634
+ assertPlanTargetsSafe(actions, homeDir, msg);
479
635
  return {
480
636
  snapshotId: snapshot.id,
481
637
  createdAt: snapshot.createdAt,
@@ -522,14 +678,14 @@ function summarizeActions(actions) {
522
678
  /* ------------------------------------------------------------ restore */
523
679
  /**
524
680
  * 执行恢复:按计划顺序 整文件还原 → 插件卸载 → file 补偿,
525
- * 覆盖/删除前全部先复制到 <snapshotDir>/pre-restore/(双保险),
681
+ * 覆盖/删除前全部先复制到 <snapshotDir>/pre-restore/<attemptDir>/(双保险,每次执行一个新目录),
526
682
  * 逐项 try/catch(单项失败不拖垮其余),输出诚实报告。
527
683
  */
528
684
  export async function restore(opts) {
529
685
  const plan = await planRestore(opts);
530
686
  const { snapshotDir, homeDir, profile } = opts;
531
687
  const msg = opts.msg ?? zhMsg;
532
- const preDir = path.join(snapshotDir, 'pre-restore');
688
+ const preRoot = path.join(snapshotDir, 'pre-restore');
533
689
  const profileDir = resolveProfileDir(homeDir, profile);
534
690
  const uninstaller = opts.pluginUninstaller
535
691
  ?? ((name, dir, prof) => defaultPluginUninstaller(name, dir, prof, msg));
@@ -541,18 +697,71 @@ export async function restore(opts) {
541
697
  failed: [],
542
698
  skipped: [],
543
699
  };
700
+ // D5:反悔副本按「本次执行」分目录(pre-restore/<attemptDir>/),惰性创建 ——
701
+ // 计划全是 skip 的恢复不会建空目录(dry-run 连 restore() 都不调,同样零写入)。
702
+ let preDir;
703
+ const preDirOf = async () => {
704
+ if (preDir === undefined) {
705
+ preDir = await createPreRestoreAttemptDir(preRoot);
706
+ report.preRestoreDir = preDir;
707
+ }
708
+ return preDir;
709
+ };
710
+ // 逐动作 WAL(可判进度,见 restore-wal.ts):绑定可信快照 → 推进 APPLYING → **首个写之前**
711
+ // 预登记**全部**动盘动作(planned + beforeFp)。预登记而不是边做边记:reconcile 只看 journal 里
712
+ // 存在的 step,只登记「已执行」的那些会把「崩在第 2 步」误判成「全部完成」。
713
+ const wal = createRestoreWal(opts.journal);
714
+ const walByAction = new Map();
715
+ const resume = opts.resume;
716
+ if (wal !== null) {
717
+ const { mutating, entries } = collectRestoreWalEntries(plan.actions, (a) => walAbsOf(a));
718
+ if (resume === undefined) {
719
+ const bindSnapshot = opts.journal?.bindSnapshot;
720
+ if (bindSnapshot !== undefined)
721
+ await bindSnapshot(plan.snapshotId);
722
+ const markApplying = opts.journal?.markApplying;
723
+ if (markApplying !== undefined)
724
+ await markApplying();
725
+ const handles = await wal.preRegister(entries);
726
+ mutating.forEach((a, i) => { walByAction.set(a, handles[i]); });
727
+ }
728
+ else {
729
+ // 续跑:只复原句柄,**绝不重新 preRegister**(见 RestoreOptions.resume 注释)。
730
+ const handles = restoreWalHandlesFromSteps(entries, resume.steps);
731
+ mutating.forEach((a, i) => { walByAction.set(a, handles[i]); });
732
+ }
733
+ }
734
+ /** 文件类动作的绝对目标(reconcile 直接拿它读盘算指纹);插件卸载/越界目标 → null(不可指纹)。 */
735
+ function walAbsOf(action) {
736
+ if (action.kind === 'pluginRemove' || action.target === undefined)
737
+ return null;
738
+ try {
739
+ return homeAbs(homeDir, action.target, msg);
740
+ }
741
+ catch {
742
+ return null;
743
+ }
744
+ }
544
745
  let seq = 0;
545
746
  const total = plan.actions.length;
546
747
  let actionIndex = 0;
547
748
  for (const action of plan.actions) {
548
749
  actionIndex += 1;
549
750
  opts.onAction?.({ index: actionIndex, total, detail: action.description });
751
+ if (resume !== undefined && resume.skipStepIds.has(restoreActionStepId(action, walAbsOf(action)))) {
752
+ // 已证明上次就写成了这个动作的终态 —— 重做只会再覆盖一次(对删除类还会误删用户新放回的文件)。
753
+ report.skipped.push(msg('restore.resumedSkip', { description: action.description }));
754
+ continue;
755
+ }
756
+ const walHandle = walByAction.get(action);
757
+ let settleStatus = 'done';
758
+ let settleMessage = null;
550
759
  try {
551
760
  switch (action.kind) {
552
761
  case 'hostFileRestore': {
553
762
  const abs = homeAbs(homeDir, action.target, msg);
554
763
  if (await fileExists(abs))
555
- await copyToPreRestore(preDir, abs, action.target, ++seq);
764
+ await copyToPreRestore(await preDirOf(), abs, action.target, ++seq);
556
765
  // 流式拷贝(t46):blob → 目标文件经 copyFile,**不经内存中转**。改动前是
557
766
  // readFile + atomicWriteFile —— 整个 blob 进堆,大 home 下与快照载荷叠加成双份驻留
558
767
  // (本机 32MB blob 实测 +32.0MB arrayBuffers;改用本原语后 +0.0MB)。
@@ -564,7 +773,7 @@ export async function restore(opts) {
564
773
  case 'hostFileRemove': {
565
774
  const abs = homeAbs(homeDir, action.target, msg);
566
775
  if (await fileExists(abs)) {
567
- await copyToPreRestore(preDir, abs, action.target, ++seq);
776
+ await copyToPreRestore(await preDirOf(), abs, action.target, ++seq);
568
777
  await fs.rm(abs, { force: true });
569
778
  }
570
779
  report.restored.push(action.target);
@@ -573,7 +782,7 @@ export async function restore(opts) {
573
782
  case 'fileRestore': {
574
783
  const abs = homeAbs(homeDir, action.target, msg);
575
784
  if (await fileExists(abs))
576
- await copyToPreRestore(preDir, abs, action.target, ++seq);
785
+ await copyToPreRestore(await preDirOf(), abs, action.target, ++seq);
577
786
  // 流式拷贝(t46):blob → 目标文件经 copyFile,**不经内存中转**。改动前是
578
787
  // readFile + atomicWriteFile —— 整个 blob 进堆,大 home 下与快照载荷叠加成双份驻留
579
788
  // (本机 32MB blob 实测 +32.0MB arrayBuffers;改用本原语后 +0.0MB)。
@@ -585,7 +794,7 @@ export async function restore(opts) {
585
794
  case 'fileRemove': {
586
795
  const abs = homeAbs(homeDir, action.target, msg);
587
796
  if (await fileExists(abs)) {
588
- await copyToPreRestore(preDir, abs, action.target, ++seq);
797
+ await copyToPreRestore(await preDirOf(), abs, action.target, ++seq);
589
798
  await fs.rm(abs, { force: true });
590
799
  }
591
800
  report.restored.push(action.target);
@@ -597,7 +806,11 @@ export async function restore(opts) {
597
806
  report.removedPlugins.push(action.pluginName);
598
807
  }
599
808
  else {
600
- report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? msg('restore.pluginRemoveFailed') });
809
+ const reason = result.message ?? msg('restore.pluginRemoveFailed');
810
+ report.failed.push({ item: `plugin:${action.pluginName}`, reason });
811
+ // 外部副作用没做成 → 记 attention(绝不记 done:reconcile 对 external step 本就保守)
812
+ settleStatus = 'attention';
813
+ settleMessage = reason;
601
814
  }
602
815
  break;
603
816
  }
@@ -612,11 +825,14 @@ export async function restore(opts) {
612
825
  }
613
826
  }
614
827
  catch (err) {
615
- report.failed.push({
616
- item: action.target ?? action.pluginName ?? action.description,
617
- reason: err instanceof Error ? err.message : String(err),
618
- });
828
+ const reason = err instanceof Error ? err.message : String(err);
829
+ report.failed.push({ item: action.target ?? action.pluginName ?? action.description, reason });
830
+ settleStatus = 'attention';
831
+ settleMessage = reason;
619
832
  }
833
+ // WAL 终态与导入引擎同口径:持久化失败向上抛(fail-closed),绝不静默丢进度。
834
+ if (wal !== null && walHandle !== undefined)
835
+ await wal.settle(walHandle, settleStatus, settleMessage);
620
836
  }
621
837
  // F5 幽灵会话校验(file 类会话条目写回后):快照记录的会话清单 vs 磁盘 sessions 目录
622
838
  // 实际文件,报告失效归档(备份有记录、磁盘无文件)。DSH 无归档会话 API,仅报告不
@@ -28,4 +28,18 @@ export declare function loadIgnoreRules(root: string): Promise<IgnoreRules>;
28
28
  export declare function ignoreRuleMatches(rules: IgnoreRules, relPath: string, isDir: boolean): boolean;
29
29
  /** 分块根 → 该路径的规则相对路径(斜杠形式)。不在分块内返回 null(调用方按无规则处理)。 */
30
30
  export declare function ignoreRelativePath(root: string, target: string): string | null;
31
+ /**
32
+ * 内置敏感文件判据:命中返回原因码 `'sensitive'`(含凭据,**不回滚**),否则 null。
33
+ *
34
+ * `relPath` 是**相对分块根**的斜杠路径(来自 {@link ignoreRelativePath})——只看文件名与后缀,
35
+ * 不看目录:密钥常散落在任意子目录(src/a.key)。
36
+ *
37
+ * 与用户忽略规则的关键区别:**不可被 `!` 反向取消**。调用方在判定处独立调用本函数,
38
+ * 而不是把它编译进 parseIgnoreRules 的 patterns —— 用户写 `!.env` 的动机通常是「我要能回滚我的
39
+ * .env」,但那会把明文密码送进内容寻址仓库;这类文件宁可少回滚,也不明文物化。
40
+ *
41
+ * 大小写折半比较(Windows / macOS 上 .ENV 与 .env 是同一个文件):宁可保守。
42
+ * 刻意放行:`.env.example` / `.env.sample` / `.env.template`(模板是文档)、`*.pub`(公钥)。
43
+ */
44
+ export declare function builtinSensitiveReason(relPath: string): string | null;
31
45
  export {};
@@ -9,10 +9,12 @@
9
9
  * - 尾 / = 只匹配目录;首 / = 相对规则文件所在目录(本实现里一律相对该目录);
10
10
  * - * / ? 不跨段,** 跨段。
11
11
  *
12
- * 两条硬边界(不得放宽):
12
+ * 三条硬边界(不得放宽):
13
13
  * ① 内置运行时依赖目录(EXCLUDED_DEP_DIR_NAMES,当前 node_modules)不可被反向取消。
14
14
  * ② 被忽略的整个目录不进走查 ⇒ 证明名单不完整 ⇒ 该点的删除集整体拒绝(fail-closed),
15
15
  * 由调用方据匹配结果置 ignoreIncomplete;被忽略的文件仍逐条进 skipped,名单照样完整。
16
+ * ③ **内置敏感文件(凭据 / 私钥)同样不可被反向取消**:它们不进对象仓(不明文物化),
17
+ * 但仍进走查名单(pathIndex)⇒ 删除集不会把它们误判成「留点后新建」。见 builtinSensitiveReason。
16
18
  */
17
19
  import fs from 'node:fs/promises';
18
20
  import path from 'node:path';
@@ -20,6 +22,24 @@ import path from 'node:path';
20
22
  export const IGNORE_RULE_FILE_NAME = '.dsh-rollback-ignore';
21
23
  /** 内置运行时依赖目录名(与 utils/recursive-walk 的唯一来源保持一致) */
22
24
  const EXCLUDED_DEP_SEGMENTS = ['node_modules'];
25
+ /**
26
+ * 内置敏感文件表(判据见 {@link builtinSensitiveReason})——**只收明确的凭据形态**,
27
+ * 不放宽成「名字里带 secret/token 就算」:误判会让用户回滚点静默少文件,比多存几个文件更糟。
28
+ *
29
+ * **不按目录名判定**:密钥常放在任意子目录(src/a.key),按目录猜会漏。
30
+ */
31
+ const SENSITIVE_BASENAMES = new Set([
32
+ '.npmrc', // npm token(_authToken)
33
+ '.pypirc', // PyPI token
34
+ '.netrc', // 明文登录口令
35
+ '.git-credentials', // git 明文凭据
36
+ '.htpasswd', // 散列口令(仍是凭据材料)
37
+ 'id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa', // OpenSSH 私钥(.pub 公钥已放行)
38
+ ]);
39
+ /** 私钥 / 证书容器后缀(文件本身就是凭据材料) */
40
+ const SENSITIVE_KEY_SUFFIXES = ['.pem', '.key', '.p12', '.pfx', '.jks', '.keystore', '.ppk'];
41
+ /** `.env.*` 里属于**模板**的形态:样例文件是文档,必须照常进快照(否则用户拷一份就丢) */
42
+ const ENV_TEMPLATE_SUFFIXES = new Set(['.example', '.sample', '.template']);
23
43
  const EMPTY_RULES = { root: '', patterns: [] };
24
44
  /** 单个字符是否需要正则转义(glob → RegExp 的唯一入口) */
25
45
  function escapeRegExpChar(ch) {
@@ -137,4 +157,37 @@ export function ignoreRelativePath(root, target) {
137
157
  return null;
138
158
  return rel.split('\\').join('/');
139
159
  }
160
+ /**
161
+ * 内置敏感文件判据:命中返回原因码 `'sensitive'`(含凭据,**不回滚**),否则 null。
162
+ *
163
+ * `relPath` 是**相对分块根**的斜杠路径(来自 {@link ignoreRelativePath})——只看文件名与后缀,
164
+ * 不看目录:密钥常散落在任意子目录(src/a.key)。
165
+ *
166
+ * 与用户忽略规则的关键区别:**不可被 `!` 反向取消**。调用方在判定处独立调用本函数,
167
+ * 而不是把它编译进 parseIgnoreRules 的 patterns —— 用户写 `!.env` 的动机通常是「我要能回滚我的
168
+ * .env」,但那会把明文密码送进内容寻址仓库;这类文件宁可少回滚,也不明文物化。
169
+ *
170
+ * 大小写折半比较(Windows / macOS 上 .ENV 与 .env 是同一个文件):宁可保守。
171
+ * 刻意放行:`.env.example` / `.env.sample` / `.env.template`(模板是文档)、`*.pub`(公钥)。
172
+ */
173
+ export function builtinSensitiveReason(relPath) {
174
+ const normalized = relPath.split('\\').join('/').replace(/^\.\//, '');
175
+ const base = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase();
176
+ if (base === '')
177
+ return null;
178
+ // .env 家族:模板放行,其余(.env / .env.local / .env.production …)一律视为凭据
179
+ if (base === '.env')
180
+ return 'sensitive';
181
+ if (base.startsWith('.env.')) {
182
+ return ENV_TEMPLATE_SUFFIXES.has(base.slice('.env'.length)) ? null : 'sensitive';
183
+ }
184
+ if (SENSITIVE_BASENAMES.has(base))
185
+ return 'sensitive';
186
+ // 公钥放行(先于后缀判定:foo.pem.pub 之类也不该算凭据)
187
+ if (base.endsWith('.pub'))
188
+ return null;
189
+ if (SENSITIVE_KEY_SUFFIXES.some((suffix) => base.endsWith(suffix)))
190
+ return 'sensitive';
191
+ return null;
192
+ }
140
193
  //# sourceMappingURL=rollback-ignore-rules.js.map
@@ -265,7 +265,9 @@ export interface RollbackSkippedEntry {
265
265
  * link(符号链接/junction,不跟随)/ missing / not-a-file / self-store /
266
266
  * dep-dir(运行时依赖目录,整棵忽略)/ ignored(用户忽略规则命中)/ too-large(超单文件上限)/
267
267
  * budget-bytes(加它会超总量预算)/ budget-files(文件数预算用尽)/
268
- * asar-container(含 `.asar` 段的路径:宿主会把它当容器解析、畸形夹具能带走宿主,因此绝不碰)
268
+ * asar-container(含 `.asar` 段的路径:宿主会把它当容器解析、畸形夹具能带走宿主,因此绝不碰)/
269
+ * sensitive(内置敏感文件:.env / 私钥 / 凭据文件 —— 不明文物化进对象仓,也不可被 `!` 规则取消;
270
+ * 仍进走查名单,故删除集不受影响,见 rollback-ignore-rules.ts 的 builtinSensitiveReason)
269
271
  */
270
272
  reason: string;
271
273
  }
@@ -71,7 +71,7 @@ import { isRecord } from '../utils/guards.js';
71
71
  import { EXCLUDED_DEP_DIR_NAMES } from '../utils/recursive-walk.js';
72
72
  import { diffLines } from '../utils/line-diff.js';
73
73
  import { projectKeyOf } from './session-select.js';
74
- import { ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules } from './rollback-ignore-rules.js';
74
+ import { builtinSensitiveReason, ignoreRelativePath, ignoreRuleMatches, loadIgnoreRules, } from './rollback-ignore-rules.js';
75
75
  import { mergeThreeWay } from './rollback-merge.js';
76
76
  import { decodeTextBytes, encodeTextBytes } from './text-fidelity.js';
77
77
  import { UndoJournalWriter, findInterruptedUndos, readUndoJournal, replayUndoJournal } from './rollback-undo-journal.js';
@@ -1417,6 +1417,15 @@ export class RollbackPointEngine {
1417
1417
  // walked(= 路径索引的来源)。否则 skipped 被记录上限截断后,「留点时已存在但被忽略」
1418
1418
  // 的文件既不在 files、也不在 skipped、更不在 pathIndex ⇒ 被当成「留点后新建」删掉。
1419
1419
  collect?.push(full);
1420
+ // t8(D3):内置敏感文件(.env / 私钥 …)**绝不进对象仓** —— 明文凭据不物化。
1421
+ // 与 node_modules 同档:用户 `!` 规则取消不了(这里独立判定,不经过 parseIgnoreRules)。
1422
+ // 位置必须在 collect 之后:它仍进走查名单 ⇒ 删除集不会把它当成「留点后新建」删掉。
1423
+ const relPath = ignoreRelativePath(chunk, full);
1424
+ const sensitive = relPath === null ? null : builtinSensitiveReason(relPath);
1425
+ if (sensitive !== null) {
1426
+ this.recordSkip(sink, 'skipped', { path: full, reason: sensitive });
1427
+ continue;
1428
+ }
1420
1429
  if (ignore !== undefined && this.ignoredByRules(ignore, full, false)) {
1421
1430
  // 文件级忽略:逐条进 skipped(名单完整时删除照常;被记录上限截断时由 pathIndex 兜底)
1422
1431
  this.recordSkip(sink, 'skipped', { path: full, reason: 'ignored' });