dsh-config-manager 0.2.4 → 0.3.0

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 (129) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1263 -1192
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +10 -0
  36. package/lib/sync/git/git-transport.js +72 -14
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/package.json +2 -2
  56. package/src/adapters/self.test.ts +126 -85
  57. package/src/adapters/self.ts +73 -6
  58. package/src/client/ConfigManagerSection.tsx +0 -3
  59. package/src/client/common/Icon.tsx +3 -0
  60. package/src/client/common/SectionComposition.tsx +6 -1
  61. package/src/client/common/info-hint-guard.test.ts +7 -5
  62. package/src/client/config-manager.module.css +254 -13
  63. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  64. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  65. package/src/client/export/ExportView.tsx +3 -1
  66. package/src/client/home/HomePanel.tsx +25 -5
  67. package/src/client/import/ImportWizardView.tsx +1 -3
  68. package/src/client/import/import-wizard-steps.tsx +1 -17
  69. package/src/client/library/ArtifactRow.tsx +13 -2
  70. package/src/client/library/LibraryPanel.tsx +12 -2
  71. package/src/client/locales.ts +53 -34
  72. package/src/client/lucide-icons.d.ts +2 -0
  73. package/src/client/market/MarketPanel.tsx +12 -11
  74. package/src/client/market/market-locales.ts +16 -16
  75. package/src/client/recovery/RollbackCard.tsx +4 -2
  76. package/src/client/recovery/recovery-locales.test.ts +4 -2
  77. package/src/client/recovery/recovery-locales.ts +23 -23
  78. package/src/client/sync/AutosyncCard.tsx +36 -10
  79. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  80. package/src/client/sync/SyncPanel.tsx +32 -33
  81. package/src/client/sync/SyncProbeRow.tsx +23 -6
  82. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  83. package/src/client/sync/sync-locales.ts +24 -24
  84. package/src/core/analyzer.ts +37 -56
  85. package/src/core/backup.test.ts +139 -1
  86. package/src/core/backup.ts +145 -4
  87. package/src/core/import-defaults.ts +13 -0
  88. package/src/core/importer.ts +12 -2
  89. package/src/core/integrity-extra-entries.test.ts +205 -0
  90. package/src/core/messages.test.ts +3 -1
  91. package/src/core/messages.ts +8 -0
  92. package/src/core/model-tools.test.ts +33 -0
  93. package/src/core/model-tools.ts +20 -4
  94. package/src/index.ts +12 -21
  95. package/src/routes/import.ts +4 -1
  96. package/src/routes/recovery.test.ts +198 -1
  97. package/src/routes/recovery.ts +194 -1
  98. package/src/routes/sync.ts +27 -8
  99. package/src/security/integrity.ts +37 -0
  100. package/src/security/unverified-file-blocks.ts +192 -0
  101. package/src/sync/autosync-config.ts +9 -5
  102. package/src/sync/autosync-scheduler.test.ts +133 -6
  103. package/src/sync/autosync-scheduler.ts +118 -14
  104. package/src/sync/config-issue-reporting.test.ts +148 -0
  105. package/src/sync/config-issue-reporting.ts +75 -0
  106. package/src/sync/gc-skip.test.ts +66 -0
  107. package/src/sync/gc-skip.ts +64 -0
  108. package/src/sync/git/git-probe.test.ts +3 -2
  109. package/src/sync/git/git-transport.test.ts +168 -7
  110. package/src/sync/git/git-transport.ts +70 -14
  111. package/src/sync/merge-preview-text.ts +37 -0
  112. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  113. package/src/sync/s3/s3-transport.ts +23 -4
  114. package/src/sync/session-tombstones.ts +90 -0
  115. package/src/sync/sync-config.test.ts +170 -1
  116. package/src/sync/sync-config.ts +184 -14
  117. package/src/sync/sync-engine.test.ts +238 -0
  118. package/src/sync/sync-engine.ts +79 -83
  119. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  120. package/src/sync/webdav/webdav-transport.ts +26 -5
  121. package/src/ui/disk-usage-view.test.ts +2 -1
  122. package/src/ui/i18n.test.ts +47 -0
  123. package/src/ui/i18n.ts +58 -38
  124. package/src/ui/import-wizard.test.ts +18 -1
  125. package/src/ui/import-wizard.ts +3 -2
  126. package/src/ui/overview-view.test.ts +9 -0
  127. package/src/ui/overview-view.ts +9 -2
  128. package/src/ui/report.test.ts +4 -3
  129. package/src/ui/report.ts +2 -1
@@ -113,12 +113,26 @@ function parseV2WebDavNamespace(ns) {
113
113
  webdav.username = o['username'];
114
114
  return webdav;
115
115
  }
116
+ /**
117
+ * 已落盘 repoUrl 的读取侧校验(SEC-01):与写入侧同一份 validateRepoUrl。
118
+ * 不合法 → 记一条明确 issue 并让调用方丢弃该配置;绝不把可疑地址继续交给凭据注入与 git 子进程。
119
+ */
120
+ function acceptPersistedRepoUrl(repoUrl, issues) {
121
+ const err = validateRepoUrl(repoUrl);
122
+ if (err === null)
123
+ return true;
124
+ issues?.push({ code: 'repo-url-invalid', channel: 'git', message: err });
125
+ return false;
126
+ }
116
127
  /**
117
128
  * 读取同步通道配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
118
129
  * 兼容旧文件:缺 schemaVersion 字段视为 v1(git 通道)。
119
130
  * 恒返回 schemaVersion=2 的规范形态(v1 读取时归一为 git)。
131
+ *
132
+ * `issues` 可选:传数组时,被丢弃的 git repoUrl(白名单校验不通过)以结构化成因追加进去,
133
+ * 供 UI/日志如实呈现(不静默吞掉)。
120
134
  */
121
- export async function readSyncConfig(dir) {
135
+ export async function readSyncConfig(dir, issues) {
122
136
  const file = path.join(dir, SYNC_CONFIG_FILE);
123
137
  let raw;
124
138
  try {
@@ -149,6 +163,8 @@ export async function readSyncConfig(dir) {
149
163
  const git = parseV1Git(obj);
150
164
  if (git === null)
151
165
  return null;
166
+ if (!acceptPersistedRepoUrl(git.repoUrl, issues))
167
+ return null;
152
168
  return { schemaVersion: 2, transport: 'git', git };
153
169
  }
154
170
  // v2 / v3:顶层 transport 选择(v3 双命名空间并存,按 transport 返回对应通道)
@@ -160,6 +176,8 @@ export async function readSyncConfig(dir) {
160
176
  const git = parseV2GitNamespace(obj['git']);
161
177
  if (git === null)
162
178
  return null;
179
+ if (!acceptPersistedRepoUrl(git.repoUrl, issues))
180
+ return null;
163
181
  return { schemaVersion: 2, transport: 'git', git };
164
182
  }
165
183
  if (transport === 'webdav') {
@@ -219,9 +237,14 @@ function readBothNamespaces(file) {
219
237
  * 读取完整的双命名空间配置(供 status 路由回填另一通道的 repoUrl/url)。
220
238
  * 文件不存在/损坏/无任何通道配置 → null(视为未配置)。
221
239
  */
222
- export async function readFullSyncConfig(dir) {
240
+ export async function readFullSyncConfig(dir, issues) {
223
241
  const file = path.join(dir, SYNC_CONFIG_FILE);
224
242
  const both = await readBothNamespaces(file);
243
+ // SEC-01/03 读侧:已落盘的 git repoUrl 过同一份白名单校验;不合法就当作没配 git 通道
244
+ // (自动同步/凭据注入都从这里取通道配置,绝不能让可疑地址继续往下走)
245
+ if (both.git !== undefined && !acceptPersistedRepoUrl(both.git.repoUrl, issues)) {
246
+ delete both.git;
247
+ }
225
248
  // 云端点通道的配置不在 sync-config.json(见 sync-cloud-config.json);这里一并对齐成完整视图
226
249
  const s3 = await readS3ChannelConfig(dir);
227
250
  const gist = await readGistChannelConfig(dir);
@@ -253,8 +276,8 @@ export async function readFullSyncConfig(dir) {
253
276
  * 读取指定通道的同步通道配置(供自动同步调度器按通道运行)。
254
277
  * 从完整双命名空间配置取对应通道构造可辨识联合 SyncConfig;该通道未配置 → null。
255
278
  */
256
- export async function readSyncConfigFor(dir, channel) {
257
- const full = await readFullSyncConfig(dir);
279
+ export async function readSyncConfigFor(dir, channel, issues) {
280
+ const full = await readFullSyncConfig(dir, issues);
258
281
  if (full === null)
259
282
  return null;
260
283
  switch (channel) {
@@ -372,10 +395,139 @@ export async function clearSyncChannel(dir, channel) {
372
395
  await writeSyncConfigTransport(dir, transport);
373
396
  return { removed: true, hasRemaining: true, transport };
374
397
  }
398
+ /**
399
+ * repoUrl 形态分类;null = 不认识的形态。
400
+ *
401
+ * 为什么要分类而不是只做正则:git 对同一个「地址」支持 http(s)、ssh、scp 式与本机路径,
402
+ * 而这三类的安全含义完全不同(网络地址要防 SSRF,本机路径要防 argv 注入)。先判形态,
403
+ * 再按形态分别收紧,比一堆互相打架的正则清楚。
404
+ */
405
+ export function classifyRepoUrl(repoUrl) {
406
+ const url = repoUrl.trim();
407
+ if (url === '' || /\s/.test(url))
408
+ return null;
409
+ // SEC-12:以 '-' 开头的「地址」会被 git 当成选项(--upload-pack=… / -c core.pager=…)
410
+ if (url.startsWith('-'))
411
+ return null;
412
+ // 'ext::sh -c …' 一类命令协议(SCM 协议语法里 scheme 与地址之间是 '::')
413
+ if (url.includes('::'))
414
+ return null;
415
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url);
416
+ if (scheme !== null) {
417
+ const name = scheme[1].toLowerCase();
418
+ if (name !== 'http' && name !== 'https' && name !== 'ssh')
419
+ return null;
420
+ // 'https:///r.git' 这类空 authority 会被 URL 解析器「跳过斜杠」后当成主机 r.git —— 必须先拦
421
+ const rest = url.slice(scheme[0].length);
422
+ if (rest === '' || rest.startsWith('/') || rest.startsWith('\\'))
423
+ return null;
424
+ let parsed;
425
+ try {
426
+ parsed = new URL(url);
427
+ }
428
+ catch {
429
+ return null;
430
+ }
431
+ if (parsed.hostname === '')
432
+ return null;
433
+ return { kind: name, host: parsed.hostname };
434
+ }
435
+ // scp 式(git@github.com:user/repo.git):只有 user@host:path 才算网络地址
436
+ const scp = /^[^\s/@:]+@([^\s/:]+):(.+)$/.exec(url);
437
+ if (scp !== null)
438
+ return { kind: 'ssh', host: scp[1] };
439
+ // 其余按本机路径处理(相对 ../bare、绝对 /home/u/r.git、盘符 D:/repos/bare)
440
+ return { kind: 'local', host: '' };
441
+ }
442
+ /** 四段点分且每段 0-255 → IPv4 字面量;否则 null(域名里的数字段不算 IP) */
443
+ function parseIpv4(host) {
444
+ const parts = host.split('.');
445
+ if (parts.length !== 4)
446
+ return null;
447
+ const nums = [];
448
+ for (const part of parts) {
449
+ if (!/^\d{1,3}$/.test(part))
450
+ return null;
451
+ const n = Number(part);
452
+ if (n > 255)
453
+ return null;
454
+ nums.push(n);
455
+ }
456
+ return [nums[0], nums[1], nums[2], nums[3]];
457
+ }
458
+ /**
459
+ * 环回 / 私网 / 链路本地 / 未指定地址(含云元数据 169.254.169.254)判定。
460
+ * SEC-03:这类地址让「客户端」去访问内网或本机端口,等于给 SSRF 开一扇门。
461
+ *
462
+ * **已知行为变更(有意,非副作用)**:SEC-02/03 之前 `https://192.168.1.10/…`、`http://127.0.0.1:3000/…`、
463
+ * `http://nas.local/…` 这类地址可以被接受。收紧后一律拒绝 —— 代价是「拿家里 NAS / 局域网 git 服务器
464
+ * 当同步目标」这条路走不通,收益是「包里的 repoUrl 无法把家目录配置推给内网监听者或云元数据端点」。
465
+ * 取舍判据:本插件的同步目标是「用户自己的远端仓库」,公网 http(s)/ssh 已覆盖该用途;本机目录仓库
466
+ * 仍走 `local` 分支(不由本函数判定)。真要支持内网目标,应做成显式开关 + 用户逐目标确认,
467
+ * 不能靠「地址字符串看起来像内网就放行」——那正是攻击者可控的判据。
468
+ */
469
+ export function isPrivateRepoHost(host) {
470
+ const h = host.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '');
471
+ if (h === '')
472
+ return true;
473
+ if (h === 'localhost' || h.endsWith('.localhost'))
474
+ return true;
475
+ const v4 = parseIpv4(h);
476
+ if (v4 !== null) {
477
+ const [a, b] = v4;
478
+ return a === 0 || a === 127 || a === 10
479
+ || (a === 172 && b >= 16 && b <= 31)
480
+ || (a === 192 && b === 168)
481
+ || (a === 169 && b === 254);
482
+ }
483
+ if (!h.includes(':'))
484
+ return false;
485
+ if (h === '::1' || h === '::')
486
+ return true;
487
+ if (/^f[cd][0-9a-f]{2}:/.test(h))
488
+ return true; // fc00::/7 唯一本地
489
+ if (/^fe[89ab][0-9a-f]?:/.test(h))
490
+ return true; // fe80::/10 链路本地
491
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
492
+ if (mapped !== null)
493
+ return isPrivateRepoHost(mapped[1]);
494
+ return false;
495
+ }
496
+ /**
497
+ * 文案里回显地址用:剥掉 userinfo(token 可能就藏在这一段),host/path 原样保留。
498
+ *
499
+ * 为什么不整串换成 `[REPO_URL]` 占位符:这里拒绝的是**用户自己刚填的**地址,他需要认出
500
+ * 「被拒的是哪一个」才能改;剥掉 `user:pass@` 之后剩下的部分正是修复所需的定位信息。
501
+ * 为什么解析失败也要剥:`https://user:tok@` 这类半截地址 `new URL` 会抛,但前缀照样是凭据。
502
+ */
503
+ export function sanitizeRepoUrlForMessage(url) {
504
+ const raw = url.trim();
505
+ try {
506
+ const parsed = new URL(raw);
507
+ if (parsed.username === '' && parsed.password === '')
508
+ return raw;
509
+ parsed.username = '';
510
+ parsed.password = '';
511
+ return parsed.toString();
512
+ }
513
+ catch {
514
+ // 只有「残缺的带 scheme 地址」(`https://user:tok@`,`new URL` 会抛但前缀就是凭据)需要剥。
515
+ // 无 scheme 的 scp 式(`git@host:path`)里 `git` 是 ssh 登录名、不是凭据 → 原样返回,
516
+ // 免得用户看到一条认不出是哪个远端的报错。
517
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw))
518
+ return raw;
519
+ return raw.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/@\s]*@/, (_m, scheme) => scheme ?? '');
520
+ }
521
+ }
375
522
  /**
376
523
  * 仓库地址合法性校验(返回错误消息;null = 合法)。
377
- * 安全约束:token 永不拼入 repoUrl —— http(s) 地址带 userinfo(username[:password]@)直接拒绝,
378
- * 引导用户把 token 放凭据字段(DSH credentials),避免 token 经 URL 泄漏进 git 历史/日志。
524
+ *
525
+ * 白名单口径(SEC-02/03/12,写入侧与读取侧共用同一份实现):
526
+ * - 只认 http(s)/ssh/scp 式网络地址与本机路径;file://、ext::、ftp://、git:// 一律拒绝;
527
+ * - 整串以 '-' 开头拒绝(git argv 位置参数以 '-' 开头会被当选项,可执行任意命令);
528
+ * - 网络地址拒绝环回/私网/链路本地主机(SSRF)—— 文案单独一条(sync.configPrivateHost),
529
+ * 不能复用「无法解析」:用户看到「解析不了」只会反复改写法,实际原因是地址指向内网;
530
+ * - 带 scheme 的地址一律拒绝 userinfo(username[:password]@)—— token 只许放凭据字段。
379
531
  */
380
532
  export function validateRepoUrl(repoUrl, msg = zhMsg) {
381
533
  if (typeof repoUrl !== 'string' || repoUrl.trim() === '') {
@@ -385,15 +537,36 @@ export function validateRepoUrl(repoUrl, msg = zhMsg) {
385
537
  if (/\s/.test(url)) {
386
538
  return msg('sync.configWhitespace');
387
539
  }
388
- if (/^https?:\/\//i.test(url)) {
389
- let parsed;
390
- try {
391
- parsed = new URL(url);
392
- }
393
- catch {
394
- return msg('sync.configUnparseable', { url });
395
- }
396
- if (parsed.username !== '' || parsed.password !== '') {
540
+ // 回显一律脱敏:错误文案会进日志、进接口响应、进 issue 截图
541
+ const echo = sanitizeRepoUrlForMessage(url);
542
+ const shape = classifyRepoUrl(url);
543
+ if (shape === null) {
544
+ return msg('sync.configUnparseable', { url: echo });
545
+ }
546
+ if (shape.kind === 'local')
547
+ return null;
548
+ if (isPrivateRepoHost(shape.host)) {
549
+ return msg('sync.configPrivateHost', { url: echo });
550
+ }
551
+ // userinfo 检查与 scheme 无关(RV1-F7):原实现只在 http/https 分支内查,于是
552
+ // `ssh://user:tok@host/r.git` 能过白名单,token 照样进 argv 与 sync-config.json。
553
+ // 口径(按「这一段是不是凭据」区分,而不是按 scheme 区分):
554
+ // - password 位:任何带 scheme 的地址都拒 —— 那是可粘贴的 secret;
555
+ // - http/https 的 username:Basic 认证的用户名与口令等同,一并拒(保持既有行为);
556
+ // - ssh 的 username:就是 ssh 登录名(`ssh://git@host`、`ssh://deploy@host` 是标准写法),
557
+ // ssh 认证只认密钥/agent,用户名不具凭据效力,因此放行 —— 拒掉会杀掉合法用法。
558
+ // scp 式(git@host:path)无 scheme、`new URL` 解析不了,本就不进这条判定。
559
+ let parsed = null;
560
+ try {
561
+ parsed = new URL(url);
562
+ }
563
+ catch {
564
+ parsed = null;
565
+ }
566
+ if (parsed !== null) {
567
+ const hasPassword = parsed.password !== '';
568
+ const hasBasicUser = parsed.username !== '' && (shape.kind === 'http' || shape.kind === 'https');
569
+ if (hasPassword || hasBasicUser) {
397
570
  return msg('sync.configUserinfo');
398
571
  }
399
572
  }
@@ -326,6 +326,14 @@ export type MergeResolution = {
326
326
  export type ApplyMergeResolutionsReport = ApplyReport & {
327
327
  pendingConflicts: SectionId[];
328
328
  };
329
+ /**
330
+ * 删除远端快照的结果(F3:显式删除路径此前对「本轮 blob GC 被放弃」完全静默)。
331
+ * `warnings` 与 push 报告同风格(空数组 = 本轮什么都没被放弃):快照已按用户意图删除,
332
+ * 「孤儿 blob 没回收」不是失败(下一轮 GC 还会再试),所以进 warnings、不进 ok。
333
+ */
334
+ export interface DeleteSnapshotResult {
335
+ warnings: string[];
336
+ }
329
337
  export declare class SyncEngine {
330
338
  private readonly ctx;
331
339
  private readonly transport;
@@ -489,12 +497,6 @@ export declare class SyncEngine {
489
497
  * 本机枚举失败或为空。用「读不到」覆盖成「全删了」会一次性标错几百条墓碑,绝不猜。
490
498
  */
491
499
  private computeSessionBookkeeping;
492
- /**
493
- * P1-5:按远端墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。
494
- * 只动载荷,不动本机已有数据 —— 墓碑不代用户删除本机文件(那是不可回滚的用户动作)。
495
- * @returns 被剔除的会话单元 id(调用方据此出可见报告,绝不静默)
496
- */
497
- private stripTombstonedSessions;
498
500
  /**
499
501
  * 裁剪远端快照:调用 transport.list() 获取全部快照(按 createdAt 升序),
500
502
  * 按 retentionPolicyProvider() 给出的 GFS 策略(最近 N / 每月一份 / 每年一份)
@@ -523,6 +525,7 @@ export declare class SyncEngine {
523
525
  * - 其余:远端最新快照 id !== lastSnapshotId ⇒ 远端被推进(或本机基线已被裁剪)→ 拦。
524
526
  * 两种情况都**无法安全断言「远端没变」**,保守拒绝并给出 force 出口。
525
527
  * - list() 抛错 → 返回 reason(调用方按原样继续并告警),绝不把「读不到」当成「没变」。
528
+ * - SYN-02:index 可由 push() 传入(同一轮只列一次远端),缺省自己 list(其它调用点行为不变)。
526
529
  */
527
530
  private remoteAdvancedCheck;
528
531
  /**
@@ -562,8 +565,19 @@ export declare class SyncEngine {
562
565
  * - **与保留策略裁剪共用同一条 `transport.delete`**(两条通道的删除语义只有一份实现),
563
566
  * 区别只在「谁点名」:这里是用户显式指定的 id,pruneRemoteSnapshots 由 RetentionPolicy 挑;
564
567
  * - **不存在视为成功**(SyncTransport 契约),所以重复点删除 / 已被裁剪掉都不会报错。
568
+ *
569
+ * F3:通道侧的 blob GC 可能「放弃本轮」(SYN-04:引用快照 404 / 读不出 ⇒ 证明不了没人引用)。
570
+ * 放弃不是删除失败,但**必须可见**(否则孤儿 blob 无人知晓地一直堆)⇒ 放弃码用与 push 报告
571
+ * 同一条 `gcSkipWarning` 渲染进返回体的 warnings。
572
+ */
573
+ deleteSnapshot(id: string): Promise<DeleteSnapshotResult>;
574
+ /**
575
+ * SYN-04b:通道最近一轮 GC 的放弃码(无则 null)。
576
+ *
577
+ * 保留为 public 只读出口(不删):属本类已发布的可见 API,路由/诊断不跑删除流程也能读到通道能力;
578
+ * 引擎内已由 deleteSnapshot 消费(放弃码 → warnings),不再是死方法。读不到(通道没这能力)= null。
565
579
  */
566
- deleteSnapshot(id: string): Promise<void>;
580
+ lastGcSkipCode(): string | null;
567
581
  /** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
568
582
  listSnapshots(): Promise<SyncSnapshotMeta[]>;
569
583
  /**
@@ -22,6 +22,7 @@ import path from 'node:path';
22
22
  import { createAdapters } from '../adapters/index.js';
23
23
  import { defaultSecretScanner } from '../core/exporter.js';
24
24
  import { Importer } from '../core/importer.js';
25
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.js';
25
26
  // issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
26
27
  import { isLocalPluginSpec } from '../core/local-plugin-pack.js';
27
28
  import { isFileSection, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.js';
@@ -34,7 +35,7 @@ import { createSnapshotFs, joinFs } from './fs.js';
34
35
  import { writeSnapshotToDir } from './layout.js';
35
36
  import { credentialsMapFromYaml, decryptCredentialsPayload, decryptSectionsPayload, encryptCredentialsPayload, encryptSectionsPayload, } from './snapshot-crypto.js';
36
37
  import { OPT_IN_SYNC_SECTIONS } from './sync-selection.js';
37
- import { nextSessionTombstones, sessionUnitIdsOfSection, stripTombstonedUnits } from './session-tombstones.js';
38
+ import { listRemoteSnapshotIndex, planSessionBookkeeping, remoteTombstonesOf, stripTombstonedSessionsInPlace, } from './session-tombstones.js';
38
39
  import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './retention-policy.js';
39
40
  import { hashSection, loadSyncState, resolveDeviceIdentity, saveSyncState } from './sync-state.js';
40
41
  import { isEncryptedSections, PROBE_UNSUPPORTED_CODE, SyncTransportError } from './transport.js';
@@ -42,8 +43,9 @@ import { msgOf, zhMsg } from '../core/messages.js';
42
43
  import { engineMsg } from './messages.js';
43
44
  import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor, writeEncryptedAncestor } from './ancestor.js';
44
45
  import { merge as mergeSections } from './merge.js';
46
+ import { previewTextOf } from './merge-preview-text.js';
47
+ import { gcSkipWarning, readGcSkipCode, reportGcSkipOnce } from './gc-skip.js';
45
48
  import { classifyMergePlan } from './risk.js';
46
- import { redact } from '../security/redaction.js';
47
49
  import { registerMessageCatalog } from '../core/engine-locale.js';
48
50
  import { createSnapshot, selectPruneCandidates } from '../core/backup.js';
49
51
  import { rollback } from '../core/rollback.js';
@@ -58,6 +60,7 @@ import { FileSnapshotStore } from '../core/backup.js';
58
60
  const zh = {
59
61
  'sync.pushRemoteAdvanced': '远端最新快照 {remote} 与本地基线 {base} 不一致:本次推送未执行(远端未写入、基线未改动)。请先同步远端改动;确认要用本机覆盖远端时,再显式选择强制推送。',
60
62
  'sync.pushAdvanceCheckFailed': '无法确认远端是否已被其它设备推进({reason}):本次推送按原样继续',
63
+ 'sync.sessionTombstonesInherited': '已从远端快照继承 {count} 条会话删除墓碑:删除继续跨设备传播(本机数据不动)',
61
64
  'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine 缺少 importer(需在 options 中注入)',
62
65
  'sync.mergePendingConflicts': '仍有 {count} 个分区未裁决({sections}):已裁决的部分已写入本机,但基线不推进,冲突下一轮会重新出现',
63
66
  'sync.baselineRexportFailed': '写回成功但重新导出本机分区失败({sections}):无法证明基线与本机一致,本次**不记基线**(远端指针保持原值,下一轮重新合并收敛)',
@@ -65,47 +68,12 @@ const zh = {
65
68
  const en = {
66
69
  'sync.pushRemoteAdvanced': 'The newest remote snapshot {remote} differs from the local baseline {base}: this push did not run (nothing written remotely, baseline untouched). Sync the remote changes first, or explicitly force the push if this machine should overwrite the remote.',
67
70
  'sync.pushAdvanceCheckFailed': 'Cannot tell whether another device advanced the remote ({reason}): this push continues as-is',
71
+ 'sync.sessionTombstonesInherited': 'Inherited {count} session deletion tombstone(s) from the remote snapshot: deletions keep propagating across devices (local data untouched)',
68
72
  'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine is missing the importer (must be injected via options)',
69
73
  'sync.mergePendingConflicts': '{count} section(s) still have no decision ({sections}): the decided parts were written locally, but the baseline is not advanced, so the conflicts will reappear next round',
70
74
  'sync.baselineRexportFailed': 'The write succeeded but re-exporting the local section(s) failed ({sections}): the baseline cannot be proven to match this machine, so no baseline was recorded (the remote pointer keeps its old value and the next merge converges again)',
71
75
  };
72
76
  registerMessageCatalog({ id: 'sync-engine-merge', zh, en });
73
- /**
74
- * 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。
75
- *
76
- * 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
77
- * (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
78
- */
79
- const MERGE_PREVIEW_TEXT_LIMIT = 2048;
80
- /** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
81
- const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
82
- /** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
83
- function previewTextOf(value) {
84
- if (value === undefined)
85
- return '';
86
- if (typeof value === 'string')
87
- return finalizePreviewText(value);
88
- if (value instanceof Uint8Array) {
89
- try {
90
- return finalizePreviewText(STRICT_UTF8.decode(value));
91
- }
92
- catch {
93
- // 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
94
- return JSON.stringify({ binary: true, bytes: value.length });
95
- }
96
- }
97
- try {
98
- return finalizePreviewText(JSON.stringify(value) ?? '');
99
- }
100
- catch {
101
- return finalizePreviewText(String(value));
102
- }
103
- }
104
- /** 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。 */
105
- function finalizePreviewText(text) {
106
- const masked = redact(text);
107
- return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
108
- }
109
77
  /** plan 的单个分区结果 → 预览视图(三侧都只回传可读文本,绝不回传原始值)。 */
110
78
  function mergePreviewSectionView(result) {
111
79
  const conflicts = result.conflicts.map((c) => ({
@@ -519,10 +487,13 @@ export class SyncEngine {
519
487
  if (encrypt && (opts.password === undefined || opts.password === '')) {
520
488
  throw new Error(this.msg('sync.encryptRequiresPassword'));
521
489
  }
490
+ // SYN-02:本轮只列一次远端 index(推进检测 + 继承会话删除墓碑共用;墓碑累积在最新快照里)
491
+ const remoteIndex = await listRemoteSnapshotIndex(this.transport);
492
+ const remoteTombstones = remoteIndex.reason === undefined ? remoteTombstonesOf(remoteIndex.metas) : [];
522
493
  // 远端推进检测:不一致 = 远端被别的设备推过(或本机基线已被裁剪)。
523
494
  // 此时推上去会覆盖/架空别人的改动 → 直接返回冲突(**不抛错**、不 upload、不记基线、不改 sync-state)。
524
495
  if (opts.force !== true) {
525
- const check = await this.remoteAdvancedCheck();
496
+ const check = await this.remoteAdvancedCheck(remoteIndex);
526
497
  if (check.reason !== undefined) {
527
498
  // 读不到远端列表 ⇒ 无法判定。按原样继续(上传本身会再暴露通道问题),但如实说明。
528
499
  warnings.push(this.msg('sync.pushAdvanceCheckFailed', { reason: check.reason }));
@@ -575,11 +546,15 @@ export class SyncEngine {
575
546
  const credentials = includeSecrets
576
547
  ? await this.buildCredentialsPayload(opts.password, warnings)
577
548
  : undefined;
578
- // P1-5:会话删除墓碑(上一次推过、本机已不存在的会话 = 用户删掉了)
579
- const bookkeeping = await this.computeSessionBookkeeping(targets, plainSections);
549
+ // P1-5 + SYN-02:本机墓碑(上次推过、本机已删)叠加上远端继承的墓碑 ——
550
+ // 不继承的话「A 删 → B 推」会漏掉墓碑,C 带旧快照就能把已删会话复活(删除只在本机传播)。
551
+ const bookkeeping = await this.computeSessionBookkeeping(targets, plainSections, remoteTombstones);
580
552
  if (bookkeeping.deletedNow.length > 0) {
581
553
  warnings.push(this.msg('sync.sessionDeletionsRecorded', { count: String(bookkeeping.deletedNow.length) }));
582
554
  }
555
+ if (bookkeeping.inherited > 0) {
556
+ warnings.push(this.msg('sync.sessionTombstonesInherited', { count: String(bookkeeping.inherited) }));
557
+ }
583
558
  // ⑥ 设备身份(cloud-sync 借鉴项 ⑥):首次生成后持久化在 sync-state.json(已有则纯读),
584
559
  // 随 manifest 上行 → 别的设备在历史列表里能看到「这份快照是哪台机器推的」。
585
560
  const device = await resolveDeviceIdentity(this.stateDir, os.hostname(), this.fsx, this.msg);
@@ -747,50 +722,33 @@ export class SyncEngine {
747
722
  * `tracked=false` 的三种情形一律**不动既有记录**:没推 sessions / 适配器不支持全量枚举 /
748
723
  * 本机枚举失败或为空。用「读不到」覆盖成「全删了」会一次性标错几百条墓碑,绝不猜。
749
724
  */
750
- async computeSessionBookkeeping(targets, plainSections) {
725
+ async computeSessionBookkeeping(targets, plainSections, remoteTombstones = []) {
751
726
  const sessions = targets.find((a) => a.id === 'sessions');
752
727
  if (sessions === undefined || sessions.listAllUnitIds === undefined) {
753
- return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
728
+ return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
754
729
  }
755
730
  let localUnits;
756
731
  try {
757
732
  localUnits = await sessions.listAllUnitIds(this.ctx);
758
733
  }
759
734
  catch {
760
- return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
735
+ return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
761
736
  }
762
737
  if (localUnits.length === 0) {
763
- return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
738
+ return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
764
739
  }
765
740
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
766
- const { tombstones, deletedNow } = nextSessionTombstones({
767
- previousUnits: state.sessionUnits ?? [],
768
- localUnits,
769
- previousTombstones: state.deletedSessions ?? [],
770
- });
771
741
  return {
772
742
  tracked: true,
773
- sessionUnits: sessionUnitIdsOfSection(plainSections['sessions']),
774
- tombstones,
775
- deletedNow,
743
+ ...planSessionBookkeeping({
744
+ previousUnits: state.sessionUnits ?? [],
745
+ previousTombstones: state.deletedSessions ?? [],
746
+ localUnits,
747
+ exportedSessions: plainSections['sessions'],
748
+ remoteTombstones,
749
+ }),
776
750
  };
777
751
  }
778
- /**
779
- * P1-5:按远端墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。
780
- * 只动载荷,不动本机已有数据 —— 墓碑不代用户删除本机文件(那是不可回滚的用户动作)。
781
- * @returns 被剔除的会话单元 id(调用方据此出可见报告,绝不静默)
782
- */
783
- stripTombstonedSessions(snapshot) {
784
- const tombstones = snapshot.manifest.deletedSessions ?? [];
785
- if (tombstones.length === 0 || isEncryptedSections(snapshot.sections))
786
- return [];
787
- const plain = snapshot.sections;
788
- const stripped = stripTombstonedUnits(plain['sessions'], tombstones);
789
- if (stripped.removed.length === 0)
790
- return [];
791
- plain['sessions'] = stripped.section;
792
- return stripped.removed;
793
- }
794
752
  /**
795
753
  * 裁剪远端快照:调用 transport.list() 获取全部快照(按 createdAt 升序),
796
754
  * 按 retentionPolicyProvider() 给出的 GFS 策略(最近 N / 每月一份 / 每年一份)
@@ -822,6 +780,10 @@ export class SyncEngine {
822
780
  const doomed = selectPruneCandidatesByPolicy(metas.map((m) => ({ id: m.id, createdAt: m.createdAt })), policy, selectPruneCandidates);
823
781
  // 刚 push 的快照永不进删除集(防御性:策略三层全关时也保住它)
824
782
  const keep = new Set([pushedId]);
783
+ // SYN-04b:本轮 GC 是否已放弃 —— 每次 transport.delete() 内部会重跑一轮 blob GC,
784
+ // 而放弃码是「本轮」的结论;同一次 push 里删 N 个快照会设置 N 次同一个原因,
785
+ // 逐条报会把「一样的事实」刷 N 遍,所以本轮只报一次。
786
+ const gcReported = { done: false };
825
787
  for (const m of metas) {
826
788
  if (doomed.includes(m.id) && !keep.has(m.id)) {
827
789
  try {
@@ -831,6 +793,7 @@ export class SyncEngine {
831
793
  const reason = err instanceof Error ? err.message : String(err);
832
794
  warnings.push(this.msg('sync.pruneDeleteFailed', { id: m.id, reason }));
833
795
  }
796
+ reportGcSkipOnce(this.transport, this.msg, warnings, gcReported);
834
797
  }
835
798
  }
836
799
  }
@@ -852,19 +815,17 @@ export class SyncEngine {
852
815
  * - 其余:远端最新快照 id !== lastSnapshotId ⇒ 远端被推进(或本机基线已被裁剪)→ 拦。
853
816
  * 两种情况都**无法安全断言「远端没变」**,保守拒绝并给出 force 出口。
854
817
  * - list() 抛错 → 返回 reason(调用方按原样继续并告警),绝不把「读不到」当成「没变」。
818
+ * - SYN-02:index 可由 push() 传入(同一轮只列一次远端),缺省自己 list(其它调用点行为不变)。
855
819
  */
856
- async remoteAdvancedCheck() {
820
+ async remoteAdvancedCheck(index) {
857
821
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
858
822
  const base = state.lastSnapshotId;
859
823
  if (base === '')
860
824
  return { blocked: false, base, latest: '' };
861
- let metas;
862
- try {
863
- metas = await this.transport.list();
864
- }
865
- catch (err) {
866
- return { blocked: false, base, latest: '', reason: err instanceof Error ? err.message : String(err) };
867
- }
825
+ const listed = index ?? await listRemoteSnapshotIndex(this.transport);
826
+ if (listed.reason !== undefined)
827
+ return { blocked: false, base, latest: '', reason: listed.reason };
828
+ const metas = listed.metas;
868
829
  if (metas.length === 0)
869
830
  return { blocked: false, base, latest: '' };
870
831
  const latest = metas[metas.length - 1].id; // list 按 createdAt 升序 → 最新在末
@@ -888,7 +849,7 @@ export class SyncEngine {
888
849
  // 加密快照 → 用密码解密回明文(无密码明确报错);普通快照 → containsSecrets 拒绝
889
850
  await this.prepareSnapshot(snapshot, opts.password);
890
851
  // P1-5:按远端墓碑剔除已删除的会话(旧快照不复活)
891
- const tombstoned = this.stripTombstonedSessions(snapshot);
852
+ const tombstoned = stripTombstonedSessionsInPlace(snapshot);
892
853
  // issue #38:快照携带凭据载荷 → 解密出 ref→值(仅内存),供计划项与后续 apply 使用
893
854
  const credentials = await this.decryptSnapshotCredentials(snapshot, opts.password);
894
855
  const portableIds = this.pullSectionIds();
@@ -951,7 +912,7 @@ export class SyncEngine {
951
912
  await this.prepareSnapshot(snapshot, opts.password);
952
913
  // audit-sync sync-F6:剔除结果必须**可见**(与 pull/preview 同一口径)。
953
914
  // 此前返回值丢弃了它 —— 产物里少了哪些会话,调用方与用户都无从得知(绝不静默少带)。
954
- const tombstonedSessions = this.stripTombstonedSessions(snapshot).length;
915
+ const tombstonedSessions = stripTombstonedSessionsInPlace(snapshot).length;
955
916
  const portableIds = this.pullSectionIds();
956
917
  const zipPath = await this.snapshotToZip(snapshot, portableIds);
957
918
  // snapshotToZip 产出在临时目录;搬到调用方指定目录(同盘 rename,跨盘退化为复制)
@@ -978,9 +939,26 @@ export class SyncEngine {
978
939
  * - **与保留策略裁剪共用同一条 `transport.delete`**(两条通道的删除语义只有一份实现),
979
940
  * 区别只在「谁点名」:这里是用户显式指定的 id,pruneRemoteSnapshots 由 RetentionPolicy 挑;
980
941
  * - **不存在视为成功**(SyncTransport 契约),所以重复点删除 / 已被裁剪掉都不会报错。
942
+ *
943
+ * F3:通道侧的 blob GC 可能「放弃本轮」(SYN-04:引用快照 404 / 读不出 ⇒ 证明不了没人引用)。
944
+ * 放弃不是删除失败,但**必须可见**(否则孤儿 blob 无人知晓地一直堆)⇒ 放弃码用与 push 报告
945
+ * 同一条 `gcSkipWarning` 渲染进返回体的 warnings。
981
946
  */
982
947
  async deleteSnapshot(id) {
983
948
  await this.transport.delete(id);
949
+ // transport.delete 内部是 await 完 gcBlobStore 才返回的(两条通道同型),所以此时取到的是
950
+ // **本次删除那一轮**的放弃码,而不是上一轮的残留。
951
+ const code = this.lastGcSkipCode();
952
+ return code === null ? { warnings: [] } : { warnings: [gcSkipWarning(this.msg, code)] };
953
+ }
954
+ /**
955
+ * SYN-04b:通道最近一轮 GC 的放弃码(无则 null)。
956
+ *
957
+ * 保留为 public 只读出口(不删):属本类已发布的可见 API,路由/诊断不跑删除流程也能读到通道能力;
958
+ * 引擎内已由 deleteSnapshot 消费(放弃码 → warnings),不再是死方法。读不到(通道没这能力)= null。
959
+ */
960
+ lastGcSkipCode() {
961
+ return readGcSkipCode(this.transport);
984
962
  }
985
963
  /** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
986
964
  async listSnapshots() {
@@ -1083,7 +1061,7 @@ export class SyncEngine {
1083
1061
  // 加密快照 → 用密码解密回明文(无密码明确报错);普通快照 → containsSecrets 拒绝
1084
1062
  await this.prepareSnapshot(snapshot, opts.password);
1085
1063
  // P1-5:按远端墓碑剔除已删除的会话(旧快照不复活;一键同步复用本 plan)
1086
- const tombstoned = this.stripTombstonedSessions(snapshot);
1064
+ const tombstoned = stripTombstonedSessionsInPlace(snapshot);
1087
1065
  // issue #38:凭据载荷解密(仅内存;由调用方登记进同步会话,apply-items 时写回本机)
1088
1066
  const credentials = await this.decryptSnapshotCredentials(snapshot, opts.password);
1089
1067
  const portableIds = this.pullSectionIds();
@@ -1128,7 +1106,7 @@ export class SyncEngine {
1128
1106
  // issue #107:记下远端快照是否加密 —— applyMergePlan 据此决定祖先副本也落密文
1129
1107
  this.lastMergedRemoteEncrypted = remote.manifest.encrypted === true;
1130
1108
  // P1-5:按远端墓碑剔除已删除的会话(三方合并的 remote 侧同样不许复活)
1131
- this.stripTombstonedSessions(remote);
1109
+ stripTombstonedSessionsInPlace(remote);
1132
1110
  // 本次合并的远端基准(P0-7):紧随其后的 applyMergePlan() 用它写基线远端指针
1133
1111
  this.lastMergedRemoteSnapshotId = targetId;
1134
1112
  // issue #45:把远端快照的 sourceHome 与用户映射一并备忘 —— applyMergePlan 重建临时 ZIP 时
@@ -1571,12 +1549,12 @@ export class SyncEngine {
1571
1549
  warnings: [this.msg('sync.preApplySnapshotFailed', { err: backupErr instanceof Error ? backupErr.message : String(backupErr) })],
1572
1550
  };
1573
1551
  }
1574
- // 3) 真正执行:Importer.executeImportPlan(rollbackOnError=true → 任一失败整体回滚)
1552
+ // 3) 真正执行:Importer.executeImportPlan(rollbackOnError 取单一缺省 → 任一失败整体回滚)
1575
1553
  // 自动同步路径没有密码(凭据载荷无法解密),因此恒不注入 decryptedCredentials;
1576
1554
  // 加密快照本就不会进入自动同步(无密码无法 prepareSnapshot)。
1577
1555
  const result = await this.importer.executeImportPlan(zipPath, plan, {
1578
1556
  confirm: true,
1579
- rollbackOnError: true,
1557
+ rollbackOnError: DEFAULT_ROLLBACK_ON_ERROR,
1580
1558
  secretInputs: undefined,
1581
1559
  decryptedCredentials: undefined,
1582
1560
  // 审计 P0-23:把 journal 绑定面透传给引擎(自动同步路径由 autosync-scheduler 提供),
@@ -1680,10 +1658,10 @@ export class SyncEngine {
1680
1658
  result: null,
1681
1659
  };
1682
1660
  }
1683
- // 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError:true → 任一失败整体回滚)
1661
+ // 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError 取单一缺省 → 任一失败整体回滚)
1684
1662
  const result = await this.importer.executeImportPlan(zipPath, subPlan, {
1685
1663
  confirm: true,
1686
- rollbackOnError: true,
1664
+ rollbackOnError: DEFAULT_ROLLBACK_ON_ERROR,
1687
1665
  secretInputs: undefined,
1688
1666
  // issue #38:快照凭据载荷解出的 ref→值(仅内存)→ credentials adapter 写回本机
1689
1667
  decryptedCredentials: opts.credentials,