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
package/src/index.ts CHANGED
@@ -132,7 +132,7 @@ import type { PruneSelector } from './core/backup.ts'
132
132
  import { selectPruneCandidates } from './core/backup.ts'
133
133
  import { appendAutosyncEntry } from './sync/sync-history.ts'
134
134
  import { MigrationStore, type MigrationKind, type MigrationResult, MIGRATION_HISTORY_DIR } from './core/migration-history.ts'
135
- import { readSyncConfig, validateRepoUrl, validateWebDavUrl, isWebDavConfig, isS3Config, isGistConfig, channelOf, parseSyncChannel, SYNC_CHANNELS, GIST_PROVIDER, isS3CompatProvider, validateCloudChannelInput, cloudSecretRef } from './sync/sync-config.ts'
135
+ import { validateRepoUrl, validateWebDavUrl, isWebDavConfig, isS3Config, isGistConfig, channelOf, parseSyncChannel, SYNC_CHANNELS, GIST_PROVIDER, isS3CompatProvider, validateCloudChannelInput, cloudSecretRef } from './sync/sync-config.ts'
136
136
  import {
137
137
  resolveSessionFormatDisposition,
138
138
  sessionFormatAbortResponse,
@@ -140,6 +140,7 @@ import {
140
140
  } from './routes/session-format.ts'
141
141
  import type { SessionFormatDisposition } from './core/types.ts'
142
142
  import type { SyncConfig, SyncTransportType } from './sync/sync-config.ts'
143
+ import { createPrepareSync } from './sync/config-issue-reporting.ts'
143
144
  import { AUTO_SYNC_OPT_IN_SECTIONS, defaultSyncSelection, effectiveSections, OPT_IN_SYNC_SECTIONS, readAllSyncSelections, readSyncSelection } from './sync/sync-selection.ts'
144
145
  import type { SyncSelection, SyncSelectionMode } from './sync/sync-selection.ts'
145
146
  import type { SyncTransport } from './sync/transport.ts'
@@ -180,7 +181,7 @@ export const name = 'config-manager'
180
181
  export const inject = ['settings', 'credentials']
181
182
 
182
183
  /** Plugin version, kept in sync with package.json ("version"). */
183
- export const PLUGIN_VERSION = '0.2.4'
184
+ export const PLUGIN_VERSION = '0.3.0'
184
185
 
185
186
  /** Plugin own package name — excluded from its own exported plugins list. */
186
187
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -2772,25 +2773,15 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
2772
2773
  // credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
2773
2774
  // 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
2774
2775
 
2775
- /**
2776
- * 解析同步请求体并补全缺失字段(委托给导出的 parseSyncBody,便于单测)。
2777
- * username 回退:webdav 请求体未带 username(如挂载时 snapshotsList 自动加载、
2778
- * 表单留空后直接同步)时,从持久化 sync-config 回填已保存的 username——
2779
- * 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
2780
- * 语义与 password 一致:留空 = 沿用已保存凭据。
2781
- */
2782
- const prepareSync = async (body: Record<string, unknown>): Promise<SyncConfig> => {
2783
- const cfg = await parseSyncBody(body, { credentials })
2784
- if (isWebDavConfig(cfg) && (cfg.webdav.username === undefined || cfg.webdav.username === '')) {
2785
- try {
2786
- const persisted = await readSyncConfig(syncDir)
2787
- return mergePersistedWebDavUsername(cfg, persisted)
2788
- } catch {
2789
- return cfg // 读失败保持原值(空 username 由 WebDavTransport 构造校验兜底报错)
2790
- }
2791
- }
2792
- return cfg
2793
- }
2776
+ // 同步请求体准备 = 「webdav username 回填 + SEC-03 配置被拒的一次性告警」。
2777
+ // 解析/去重/文案与读侧语义都在 src/sync/config-issue-reporting.ts(含单测),
2778
+ // 这里只做装配:宿主只提供 credentials、日志出口与配置目录。
2779
+ const prepareSync = createPrepareSync({
2780
+ syncDir,
2781
+ parseBody: (body) => parseSyncBody(body, { credentials }),
2782
+ mergeWebDavUsername: mergePersistedWebDavUsername,
2783
+ log: (line) => host.log.warn(line),
2784
+ })
2794
2785
 
2795
2786
  /** 同步分区选择缓存(按通道;sync-selection.json;makeSyncEngine 同步读取用,保存路由更新)。
2796
2787
  * 缺失通道 = 尚未加载(启动竞态窗口);读取/使用处兜底 defaultSyncSelection。 */
@@ -10,6 +10,7 @@ import type { WebRoute } from './kit.ts'
10
10
  import type { RoutesEnv } from './context.ts'
11
11
  import type { RunState } from '../core/run-registry.ts'
12
12
  import type { ImportPlan } from '../core/types.ts'
13
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.ts'
13
14
  import { decryptErrorText, isControlledPath, tryDecryptCredentials, writeRequestBodyToFile } from './route-support.ts'
14
15
  import { resolveSessionFormatDisposition, sessionFormatAbortResponse } from './session-format.ts'
15
16
  import { ENCRYPTED_CONTAINER_CODE, SecurityError, decryptArchive, isArchiveBlob, readContainerKind, verifyEncryptedBlob } from '../security/index.ts'
@@ -252,7 +253,9 @@ export function importRoutes(env: RoutesEnv): WebRoute[] {
252
253
  opts['secretInputs'] !== null && typeof opts['secretInputs'] === 'object'
253
254
  ? opts['secretInputs'] as Record<string, string>
254
255
  : {},
255
- rollbackOnError: opts['rollbackOnError'] === true,
256
+ // IE-02:调用方没写就落全仓同一缺省,别在这里另立一份 true/false
257
+ rollbackOnError:
258
+ typeof opts['rollbackOnError'] === 'boolean' ? opts['rollbackOnError'] : DEFAULT_ROLLBACK_ON_ERROR,
256
259
  decryptedCredentials,
257
260
  // Phase 4 生产 snapshot 接线:deferred journal 绑定的 ctx 透传给引擎,
258
261
  // 使快照创建后立即 bindSnapshot(SNAPSHOT_CREATED)→ markApplying(APPLYING)再执行。
@@ -13,7 +13,7 @@
13
13
  import test from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
15
  import http from 'node:http';
16
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
17
  import { utimesSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
@@ -22,6 +22,7 @@ import type { Server } from 'node:http';
22
22
  import { recoveryRoutes } from './recovery.ts';
23
23
  import { routeSpecOf, type WebRoute } from './kit.ts';
24
24
  import { DshProfileManager } from '../profiles/dsh-profile-manager.ts';
25
+ import { parseZip } from '../utils/zip.ts';
25
26
 
26
27
  const BASE = '/api/dsh-config-manager/recovery';
27
28
 
@@ -569,3 +570,199 @@ test('t8 源码级:判据取自 preview 的 session.active,路由**不自己
569
570
  assert.match(source, /rewindTouchesWorkspace\(rewindInput\)/, '是否要动工作区必须有唯一判据');
570
571
  assert.equal((source.match(/sessionActiveGateApplies\(/g) ?? []).length, 3, '判定链位置只有一份判据(定义 + R1 + R2)');
571
572
  });
573
+
574
+ /* ------------------------------------------------------------------ BK-01
575
+ * 反悔副本(pre-restore)只读出口:恢复覆盖前的原文落在 <snapshotDir>/pre-restore/<attempt>/
576
+ * (src/core/restore.ts 的 createPreRestoreAttemptDir / copyToPreRestore,命名 `NNNN-<label>`),
577
+ * 但此前 src/client、src/ui 零引用 ⇒ 用户既看不见也取不出来。这里只做两件只读的事:
578
+ * ① GET /recovery/pre-restore → 列出每个快照下的 attempt 目录与文件清单;
579
+ * ② POST /recovery/pre-restore/export → 把某个 attempt 目录打成 zip,落到用户指定目录。
580
+ * 硬约束:整条链路**不写快照目录一个字节**;zip 只写「用户显式指定的导出目录」,
581
+ * 且目标已存在 / 目标落在源目录内一律拒绝(禁止就地覆盖任何源文件)。
582
+ */
583
+
584
+ interface PreRestoreFixture { root: string; out: string; cleanup: () => void }
585
+
586
+ /** 造一个快照根:两个带反悔副本的快照 + 一个没有 pre-restore 的快照。 */
587
+ function preRestoreFixture(): PreRestoreFixture {
588
+ const root = mkdtempSync(join(tmpdir(), 'dcm-bk01-snaps-'));
589
+ const out = mkdtempSync(join(tmpdir(), 'dcm-bk01-out-'));
590
+ const a = join(root, 'snap-a', 'pre-restore', '20261005-101010-123');
591
+ mkdirSync(a, { recursive: true });
592
+ writeFileSync(join(a, '0001-settings.yaml'), 'old-settings');
593
+ writeFileSync(join(a, '0002-sessions_x.jsonl'), 'old-session-line');
594
+ const b = join(root, 'snap-b', 'pre-restore', '20261006-090000-500');
595
+ mkdirSync(b, { recursive: true });
596
+ writeFileSync(join(b, '0001-plugins.yaml'), 'old-plugins');
597
+ mkdirSync(join(root, 'snap-c'), { recursive: true }); // 没有 pre-restore
598
+ return { root, out, cleanup: () => { rmSync(root, { recursive: true, force: true }); rmSync(out, { recursive: true, force: true }); } };
599
+ }
600
+
601
+ function envWithPreRestore(root: string, overrides: Record<string, unknown> = {}): unknown {
602
+ return {
603
+ dataDir: '/tmp/dcm-bk01-data',
604
+ snapshotsDir: root,
605
+ host: { homeDir: '/tmp/dcm-bk01-home', profile: 'web', log: { warn: () => {} } },
606
+ profiles: { listIncompleteCopies: () => [] },
607
+ recoveryOrchestrator: { status: async () => ({ status: 200, body: { incidents: [], running: [] } }) },
608
+ sessionHealth: {},
609
+ makeRecoveryExecutors: () => ({}),
610
+ tryAppendHistory: async () => undefined,
611
+ ...overrides,
612
+ };
613
+ }
614
+
615
+ test('BK-01/①:GET /recovery/pre-restore 列出每个快照下的 attempt 目录与文件清单(相对路径/大小/mtime)', async () => {
616
+ const f = preRestoreFixture();
617
+ try {
618
+ const route = routeOf(envWithPreRestore(f.root));
619
+ await withServer(route, async (base) => {
620
+ const res = await fetch(base + '/pre-restore');
621
+ assert.equal(res.status, 200);
622
+ const body = await res.json() as {
623
+ snapshots?: { snapshotId: string; attempts: { attempt: string; files: { relativePath: string; sizeBytes: number; mtimeMs: number }[] }[] }[];
624
+ unreadable?: unknown;
625
+ };
626
+ const ids = (body.snapshots ?? []).map((s) => s.snapshotId);
627
+ assert.deepEqual(ids.sort(), ['snap-a', 'snap-b'], '没有反悔副本的快照不必出现,但也不能凭空造');
628
+ const snapA = body.snapshots!.find((s) => s.snapshotId === 'snap-a')!;
629
+ assert.equal(snapA.attempts.length, 1, '同一快照下每次恢复一个新 attempt 目录');
630
+ assert.equal(snapA.attempts[0]!.attempt, '20261005-101010-123');
631
+ const files = snapA.attempts[0]!.files.map((x) => x.relativePath).sort();
632
+ assert.deepEqual(files, ['0001-settings.yaml', '0002-sessions_x.jsonl'], '文件清单按 attempt 目录内的相对路径给出');
633
+ const first = snapA.attempts[0]!.files.find((x) => x.relativePath === '0001-settings.yaml')!;
634
+ assert.equal(first.sizeBytes, 'old-settings'.length, '大小如实(反悔副本是原文)');
635
+ assert.equal(typeof first.mtimeMs, 'number');
636
+ assert.equal(body.unreadable, null, '读得动就必须如实说「没读不动」');
637
+ });
638
+ } finally {
639
+ f.cleanup();
640
+ }
641
+ });
642
+
643
+ test('BK-01/②:POST /recovery/pre-restore/export 把某个 attempt 打成 zip 落到指定目录,源目录字节不变', async () => {
644
+ const f = preRestoreFixture();
645
+ try {
646
+ const src = join(f.root, 'snap-a', 'pre-restore', '20261005-101010-123');
647
+ const before = { a: readFileSync(join(src, '0001-settings.yaml'), 'utf8'), b: readFileSync(join(src, '0002-sessions_x.jsonl'), 'utf8') };
648
+ const route = routeOf(envWithPreRestore(f.root));
649
+ await withServer(route, async (base) => {
650
+ const res = await fetch(base + '/pre-restore/export', {
651
+ method: 'POST',
652
+ headers: { 'content-type': 'application/json' },
653
+ body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: f.out }),
654
+ });
655
+ const body = await res.json() as { ok?: boolean; zipPath?: string; entries?: number; code?: string };
656
+ assert.equal(res.status, 200, JSON.stringify(body));
657
+ assert.equal(body.ok, true);
658
+ const zipPath = body.zipPath!;
659
+ assert.equal(existsSync(zipPath), true, 'zip 必须真的落盘');
660
+ assert.equal(join(zipPath).startsWith(f.out), true, '只许写进用户指定的导出目录');
661
+ assert.equal(body.entries, 2);
662
+ const entries = parseZip(readFileSync(zipPath)).entries().map((e) => e.name).sort();
663
+ assert.deepEqual(entries, ['0001-settings.yaml', '0002-sessions_x.jsonl'], 'zip 里是 attempt 目录内的相对路径');
664
+ });
665
+ assert.equal(readFileSync(join(src, '0001-settings.yaml'), 'utf8'), before.a, '源文件字节不得变化');
666
+ assert.equal(readFileSync(join(src, '0002-sessions_x.jsonl'), 'utf8'), before.b);
667
+ assert.deepEqual(readdirSync(src).sort(), ['0001-settings.yaml', '0002-sessions_x.jsonl'], '不得在源目录里留任何产物');
668
+ } finally {
669
+ f.cleanup();
670
+ }
671
+ });
672
+
673
+ test('BK-01/③:snapshotId / attempt 带穿越(../、绝对路径)→ 400,零写入', async () => {
674
+ const f = preRestoreFixture();
675
+ try {
676
+ const route = routeOf(envWithPreRestore(f.root));
677
+ await withServer(route, async (base) => {
678
+ for (const body of [
679
+ { snapshotId: '../etc', attempt: '20261005-101010-123', outputDir: f.out },
680
+ { snapshotId: 'snap-a', attempt: '../snap-b', outputDir: f.out },
681
+ { snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: '../escape' },
682
+ ]) {
683
+ const res = await fetch(base + '/pre-restore/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
684
+ assert.equal(res.status, 400, JSON.stringify(body));
685
+ }
686
+ assert.deepEqual(readdirSync(f.out), [], '拒绝路径不得留下 zip');
687
+ });
688
+ } finally {
689
+ f.cleanup();
690
+ }
691
+ });
692
+
693
+ test('BK-01/④:导出目录不存在 / attempt 不存在 → 400 / 404,且绝不静默成功', async () => {
694
+ const f = preRestoreFixture();
695
+ try {
696
+ const route = routeOf(envWithPreRestore(f.root));
697
+ await withServer(route, async (base) => {
698
+ const missingDir = await fetch(base + '/pre-restore/export', {
699
+ method: 'POST', headers: { 'content-type': 'application/json' },
700
+ body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: join(f.out, 'nope') }),
701
+ });
702
+ assert.equal(missingDir.status, 400);
703
+ const missingAttempt = await fetch(base + '/pre-restore/export', {
704
+ method: 'POST', headers: { 'content-type': 'application/json' },
705
+ body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20990101-000000-000', outputDir: f.out }),
706
+ });
707
+ assert.equal(missingAttempt.status, 404);
708
+ assert.deepEqual(readdirSync(f.out), [], '两种拒绝都不得留下产物');
709
+ });
710
+ } finally {
711
+ f.cleanup();
712
+ }
713
+ });
714
+
715
+ test('BK-01/⑤:目标 zip 已存在 → 409,绝不就地覆盖既有文件', async () => {
716
+ const f = preRestoreFixture();
717
+ try {
718
+ const route = routeOf(envWithPreRestore(f.root));
719
+ await withServer(route, async (base) => {
720
+ const body = JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: f.out });
721
+ const first = await fetch(base + '/pre-restore/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
722
+ assert.equal(first.status, 200);
723
+ const zipPath = (await first.json() as { zipPath: string }).zipPath;
724
+ writeFileSync(zipPath, 'user-owned-bytes');
725
+ const second = await fetch(base + '/pre-restore/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
726
+ assert.equal(second.status, 409);
727
+ assert.equal(readFileSync(zipPath, 'utf8'), 'user-owned-bytes', '已存在的目标文件必须原样保留');
728
+ });
729
+ } finally {
730
+ f.cleanup();
731
+ }
732
+ });
733
+
734
+ test('BK-01/⑥:导出目录落在 attempt 目录内 → 400,禁止往源目录里写', async () => {
735
+ const f = preRestoreFixture();
736
+ try {
737
+ const src = join(f.root, 'snap-a', 'pre-restore', '20261005-101010-123');
738
+ const route = routeOf(envWithPreRestore(f.root));
739
+ await withServer(route, async (base) => {
740
+ const res = await fetch(base + '/pre-restore/export', {
741
+ method: 'POST', headers: { 'content-type': 'application/json' },
742
+ body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: src }),
743
+ });
744
+ assert.equal(res.status, 400);
745
+ assert.deepEqual(readdirSync(src).sort(), ['0001-settings.yaml', '0002-sessions_x.jsonl'], '源目录里一个字节都不许新增');
746
+ });
747
+ } finally {
748
+ f.cleanup();
749
+ }
750
+ });
751
+
752
+ test('BK-01/⑦:快照根本身读不动 → 200 + readOnly + unreadable,不假装「没有反悔副本」', async () => {
753
+ const f = preRestoreFixture();
754
+ try {
755
+ // 指向一个不存在的根:readdir 失败 ⇒ 必须如实外露原因("没有" 与 "读不到" 不能混为一谈)
756
+ const route = routeOf(envWithPreRestore(join(f.root, 'no-such-root')));
757
+ await withServer(route, async (base) => {
758
+ const res = await fetch(base + '/pre-restore');
759
+ assert.equal(res.status, 200, '读不动是「说不清」,不是 500');
760
+ const body = await res.json() as { readOnly?: boolean; snapshots?: unknown[]; unreadable?: unknown };
761
+ assert.equal(body.readOnly, true);
762
+ assert.deepEqual(body.snapshots, []);
763
+ assert.notEqual(body.unreadable, null, '读不动必须外露,不能返回空清单冒充「没有」');
764
+ });
765
+ } finally {
766
+ f.cleanup();
767
+ }
768
+ });
@@ -21,7 +21,11 @@ import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/e
21
21
  import { scanSessionHealth } from '../utils/session-health-scan.ts'
22
22
  import { applySessionTitles } from '../core/session-meta.ts'
23
23
  import { applySessionLogTitles } from '../core/session-title.ts'
24
- import { join } from 'node:path'
24
+ import { isAbsolute, join, resolve } from 'node:path'
25
+ import { readFile, readdir, stat } from 'node:fs/promises'
26
+ import { isPathSafe, isSameOrChild } from '../utils/paths.ts'
27
+ import { writeZip, type ZipWriteEntry } from '../utils/zip.ts'
28
+ import { isValidSnapshotId } from '../core/restore.ts'
25
29
  import {
26
30
  applySessionRepair,
27
31
  listSessionRepairs,
@@ -312,6 +316,160 @@ function incompleteCopiesOf(profiles: {
312
316
  }
313
317
  }
314
318
 
319
+ /* ------------------------------------------------------------------ BK-01
320
+ * 反悔副本(pre-restore)只读出口。
321
+ *
322
+ * 恢复链路在覆盖/删除任何文件前,会把当前原文复制到
323
+ * `<snapshotsDir>/<snapshotId>/pre-restore/<attempt>/`(src/core/restore.ts 的
324
+ * createPreRestoreAttemptDir / copyToPreRestore,文件名 `NNNN-<label>`)。
325
+ * BK-01:这份数据此前在 host 侧零出口 —— 用户既看不见、也取不出来,等于「存在但不可达」。
326
+ * 这里只做两件**零写入**的事:列清单(GET)与把某个 attempt 打成 zip 落到用户指定的导出目录(POST)。
327
+ *
328
+ * 硬边界:
329
+ * - 整条链路不写快照目录一个字节(源目录里连临时文件都不许留);
330
+ * - zip 只落用户显式给出的**已存在**目录,目标同名文件已存在即 409(绝不就地覆盖任何既有文件);
331
+ * - 导出目录落在源目录之内(或源目录落在导出目录之内)一律 400。
332
+ */
333
+ const PRE_RESTORE_DIR = 'pre-restore'
334
+ /** 单次枚举的上限:attempt 数 / 文件数 / 递归深度 / 单次导出总字节。防一个坏目录把首屏拖垮。 */
335
+ const PRE_RESTORE_MAX_ATTEMPTS = 200
336
+ const PRE_RESTORE_MAX_FILES = 2000
337
+ const PRE_RESTORE_MAX_DEPTH = 8
338
+ const PRE_RESTORE_EXPORT_MAX_BYTES = 256 * 1024 * 1024
339
+ /** attempt 目录名 = 恢复时间戳 `YYYYMMDD-HHMMSS-mmm`(restore.ts 的 preRestoreStamp),可选 `-N` 去重后缀。 */
340
+ const PRE_RESTORE_ATTEMPT_RE = /^[0-9]{8}-[0-9]{6}-[0-9]{3}(-[0-9]+)?$/
341
+
342
+ interface PreRestoreFile { relativePath: string; sizeBytes: number; mtimeMs: number }
343
+ interface PreRestoreAttempt { attempt: string; files: PreRestoreFile[]; totalBytes: number; skippedLinks: number; truncated: boolean }
344
+ interface PreRestoreSnapshot { snapshotId: string; attempts: PreRestoreAttempt[]; unreadable?: string; truncated?: true }
345
+ interface PreRestoreListing { readOnly: true; snapshots: PreRestoreSnapshot[]; unreadable: string | null }
346
+
347
+ function isPreRestoreAttemptName(name: unknown): name is string {
348
+ return typeof name === 'string' && PRE_RESTORE_ATTEMPT_RE.test(name)
349
+ }
350
+
351
+ interface PreRestoreWalk { files: PreRestoreFile[]; totalBytes: number; skippedLinks: number; truncated: boolean }
352
+
353
+ /**
354
+ * 递归走一遍 attempt 目录,只**读元数据**(readdir + stat),不读文件内容。
355
+ * symlink 不跟随(只计数):反悔副本里出现链接本身就可疑,跟过去会把 home 外的东西带进 zip。
356
+ * 超深度 / 超文件数 / 超字节即 `truncated` —— 调用方必须把截断当成「没说全」,不能当成「就这么多」。
357
+ */
358
+ async function walkPreRestore(dir: string, maxBytes?: number): Promise<PreRestoreWalk> {
359
+ const files: PreRestoreFile[] = []
360
+ let totalBytes = 0
361
+ let skippedLinks = 0
362
+ let truncated = false
363
+ const walk = async (current: string, rel: string, depth: number): Promise<void> => {
364
+ if (truncated) return
365
+ if (depth > PRE_RESTORE_MAX_DEPTH) { truncated = true; return }
366
+ const entries = await readdir(current, { withFileTypes: true }).catch(() => null)
367
+ if (entries === null) { truncated = true; return }
368
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
369
+ for (const entry of entries) {
370
+ if (truncated) return
371
+ const abs = join(current, entry.name)
372
+ const relativePath = rel === '' ? entry.name : rel + '/' + entry.name
373
+ if (entry.isSymbolicLink()) { skippedLinks += 1; continue }
374
+ if (entry.isDirectory()) { await walk(abs, relativePath, depth + 1); continue }
375
+ if (!entry.isFile()) { skippedLinks += 1; continue }
376
+ const info = await stat(abs).catch(() => null)
377
+ if (info === null || !info.isFile()) { skippedLinks += 1; continue }
378
+ if (files.length >= PRE_RESTORE_MAX_FILES) { truncated = true; return }
379
+ if (maxBytes !== undefined && totalBytes + info.size > maxBytes) { truncated = true; return }
380
+ files.push({ relativePath, sizeBytes: info.size, mtimeMs: info.mtimeMs })
381
+ totalBytes += info.size
382
+ }
383
+ }
384
+ await walk(dir, '', 0)
385
+ return { files, totalBytes, skippedLinks, truncated }
386
+ }
387
+
388
+ /** 列出快照根下每个快照的 pre-restore attempt 与文件清单。只读;快照根读不动如实外露。 */
389
+ async function listPreRestore(snapshotsDir: string): Promise<PreRestoreListing> {
390
+ const roots = await readdir(snapshotsDir, { withFileTypes: true }).catch(() => null)
391
+ if (roots === null) {
392
+ // 与 listSnapshots 同口径:读不动就如实说「读不动」,不抛 500、也不假装「没有反悔副本」
393
+ return { readOnly: true, snapshots: [], unreadable: snapshotsDir }
394
+ }
395
+ const snapshots: PreRestoreSnapshot[] = []
396
+ const ids = roots.filter((e) => e.isDirectory()).map((e) => e.name).sort()
397
+ for (const snapshotId of ids) {
398
+ if (!isValidSnapshotId(snapshotId)) continue
399
+ const base = join(snapshotsDir, snapshotId, PRE_RESTORE_DIR)
400
+ const entries = await readdir(base, { withFileTypes: true }).catch((error: unknown) => error)
401
+ if (!Array.isArray(entries)) {
402
+ const code = (entries as { code?: unknown }).code
403
+ // ENOENT = 这个快照从未做过恢复(正常态,不列);其余读不动如实标注
404
+ if (code === 'ENOENT' || code === 'ENOTDIR') continue
405
+ snapshots.push({ snapshotId, attempts: [], unreadable: entries instanceof Error ? entries.message : String(entries) })
406
+ continue
407
+ }
408
+ const attempts = entries.filter((e) => e.isDirectory() && isPreRestoreAttemptName(e.name)).map((e) => e.name)
409
+ if (attempts.length === 0) continue
410
+ attempts.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) // 新 → 旧
411
+ const kept = attempts.slice(0, PRE_RESTORE_MAX_ATTEMPTS)
412
+ const list: PreRestoreAttempt[] = []
413
+ for (const attempt of kept) {
414
+ const walked = await walkPreRestore(join(base, attempt))
415
+ list.push({ attempt, ...walked })
416
+ }
417
+ snapshots.push({ snapshotId, attempts: list, ...(attempts.length > kept.length ? { truncated: true as const } : {}) })
418
+ }
419
+ return { readOnly: true, snapshots, unreadable: null }
420
+ }
421
+
422
+ type PreRestoreExportResult =
423
+ | { ok: true; zipPath: string; entries: number; totalBytes: number; skippedLinks: number }
424
+ | { ok: false; status: number; code: string; detail?: string }
425
+
426
+ /**
427
+ * 把某个 attempt 目录打成 zip 落到用户指定目录。
428
+ * 码表(界面按 code 出文案,不靠状态码猜原因):
429
+ * invalid-input 400 / attempt-not-found 404 / export-dir-missing 400 /
430
+ * export-inside-source 400 / export-target-exists 409 / export-too-large 413
431
+ */
432
+ async function exportPreRestoreZip(snapshotsDir: string, body: Record<string, unknown>): Promise<PreRestoreExportResult> {
433
+ const fail = (status: number, code: string, detail?: string): PreRestoreExportResult =>
434
+ ({ ok: false, status, code, ...(detail !== undefined ? { detail } : {}) })
435
+ const { snapshotId, attempt, outputDir } = body
436
+ if (!isValidSnapshotId(snapshotId)) return fail(400, 'invalid-input', 'snapshotId')
437
+ if (!isPreRestoreAttemptName(attempt)) return fail(400, 'invalid-input', 'attempt')
438
+ // 导出目录必须是**绝对路径**且不带上跳段:相对路径会按宿主进程 cwd 解析,用户根本不知道写到哪去了
439
+ if (typeof outputDir !== 'string' || outputDir.trim() === '' || !isAbsolute(outputDir) || outputDir.split(/[\\/]+/).includes('..')) {
440
+ return fail(400, 'invalid-input', 'outputDir')
441
+ }
442
+ const outDir = resolve(outputDir)
443
+ const attemptDir = resolve(join(snapshotsDir, snapshotId, PRE_RESTORE_DIR, attempt))
444
+ // 双保险:id / attempt 已过白名单,再按解析后的路径确认没跳出快照根
445
+ if (!isSameOrChild(attemptDir, resolve(snapshotsDir))) return fail(400, 'invalid-input', 'attempt')
446
+ const srcInfo = await stat(attemptDir).catch(() => null)
447
+ if (srcInfo === null || !srcInfo.isDirectory()) return fail(404, 'attempt-not-found')
448
+ // 导出目录必须已存在:不替用户造目录(造出来的目录没人认,还可能落在错的盘上)
449
+ const outInfo = await stat(outDir).catch(() => null)
450
+ if (outInfo === null || !outInfo.isDirectory()) return fail(400, 'export-dir-missing')
451
+ // 双向 containment 都是「往源里写」:导出目录在 attempt 内 / attempt 在导出目录内(含快照根本身)
452
+ if (isSameOrChild(outDir, attemptDir) || isSameOrChild(attemptDir, outDir)) return fail(400, 'export-inside-source')
453
+ const zipPath = join(outDir, `pre-restore-${snapshotId}-${attempt}.zip`)
454
+ const walked = await walkPreRestore(attemptDir, PRE_RESTORE_EXPORT_MAX_BYTES)
455
+ if (walked.truncated) return fail(413, 'export-too-large')
456
+ // 目标已存在 ⇒ 409:writeZip 内部会 rename 覆盖,绝不能让「导出」顺手抹掉用户已有的文件
457
+ if ((await stat(zipPath).catch(() => null)) !== null) return fail(409, 'export-target-exists')
458
+ const entries: ZipWriteEntry[] = []
459
+ for (const file of walked.files) {
460
+ if (!isPathSafe(file.relativePath)) return fail(400, 'invalid-input', 'entry')
461
+ const data = await readFile(join(attemptDir, file.relativePath)).catch(() => null)
462
+ if (data === null) return fail(500, 'read-failed', file.relativePath)
463
+ entries.push({ name: file.relativePath, data: new Uint8Array(data) })
464
+ }
465
+ try {
466
+ await writeZip(zipPath, entries, { maxEntries: PRE_RESTORE_MAX_FILES })
467
+ } catch (error) {
468
+ return fail(500, 'zip-write-failed', error instanceof Error ? error.message : String(error))
469
+ }
470
+ return { ok: true, zipPath, entries: entries.length, totalBytes: walked.totalBytes, skippedLinks: walked.skippedLinks }
471
+ }
472
+
315
473
  export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
316
474
  const {
317
475
  dataDir,
@@ -320,6 +478,7 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
320
478
  profiles,
321
479
  recoveryOrchestrator,
322
480
  sessionHealth,
481
+ snapshotsDir,
323
482
  tryAppendHistory,
324
483
  } = env
325
484
  return [
@@ -521,6 +680,40 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
521
680
  }
522
681
  return
523
682
  }
683
+ // ------------------------------- BK-01:反悔副本(pre-restore)只读出口
684
+ // 挂在既有 recovery prefix 内,按 segments 分发 —— **不新增 endpoint 注册点**(本族本就是 prefix
685
+ // 路由,路由总数仍是 83;窗口守卫钉住那 7 条不受影响)。
686
+ // 零写入:GET 只读元数据;POST 只往用户显式指定的导出目录写一个 zip,快照目录一字节不动。
687
+ if (segments[0] === 'pre-restore') {
688
+ const sub = segments.slice(1)
689
+ if (sub.length === 0) {
690
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }); return }
691
+ writeJson(res, 200, await listPreRestore(snapshotsDir))
692
+ return
693
+ }
694
+ if (sub.length === 1 && sub[0] === 'export') {
695
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }); return }
696
+ const body = await readJsonBody(req)
697
+ if (body === undefined) { writeJson(res, 400, { error: 'invalid-input', code: 'invalid-input' }); return }
698
+ const result = await exportPreRestoreZip(snapshotsDir, body)
699
+ if (!result.ok) {
700
+ writeJson(res, result.status, { error: result.code, code: result.code, ...(result.detail !== undefined ? { detail: result.detail } : {}) })
701
+ return
702
+ }
703
+ // 导出是「用户拿到了一份文件」这件事实,照既有 recovery 子路径的口径落一条历史
704
+ await tryAppendHistory({
705
+ kind: 'recovery',
706
+ result: 'success',
707
+ sections: [],
708
+ source: 'recovery',
709
+ summary: engineMsg('route.recoveryOperationSummary', { action: 'export-pre-restore' }),
710
+ }).catch(() => undefined)
711
+ writeJson(res, 200, result)
712
+ return
713
+ }
714
+ writeJson(res, 404, { error: 'not found' })
715
+ return
716
+ }
524
717
  // ---------------------------------------- S1–S8:工作区 + 会话回滚点
525
718
  // 回滚点 = 工作区快照 + 会话游标(宿主按回合自动留点)。**没有手动「拍点」表单**,
526
719
  // 因此这里没有 capture 子路径 —— 留点由宿主订阅 session/event 后走引擎。
@@ -49,7 +49,7 @@ import { shouldRunOnEnable, readAutosyncConfig, writeAutosyncConfig, clampRemote
49
49
  import { isValidExportFileName } from '../sync/backup-files.ts'
50
50
  import { DeviceFlowStore } from '../sync/github-auth.ts'
51
51
  import { channelOf, clearSyncChannel, isWebDavConfig, listCloudConfiguredProviders, parseSyncChannel, readFullSyncConfig, writeSyncConfig, gistChannelView, s3ChannelView, cloudSecretRef, S3_COMPAT_PROVIDERS, GIST_PROVIDER } from '../sync/sync-config.ts'
52
- import type { CloudS3ChannelView, CloudGistChannelView, CloudSecretWriter, FullSyncConfig, SyncConfig, SyncTransportType } from '../sync/sync-config.ts'
52
+ import type { CloudS3ChannelView, CloudGistChannelView, CloudSecretWriter, FullSyncConfig, SyncConfig, SyncConfigIssue, SyncTransportType } from '../sync/sync-config.ts'
53
53
  import { SyncEngine } from '../sync/sync-engine.ts'
54
54
  import { isProbeUnsupported } from '../sync/transport.ts'
55
55
  import type { ApplyItemsReport } from '../sync/sync-engine.ts'
@@ -198,8 +198,12 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
198
198
  */
199
199
  const collectSyncStatus = async (opts: { workDirDetail: boolean }): Promise<Record<string, unknown>> => {
200
200
  // 完整双命名空间配置:repoUrl / webdav.url 无论当前通道都回填,
201
- // 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失
202
- const full = await readFullSyncConfig(syncDir)
201
+ // 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失。
202
+ // SEC-03 读侧可见性:已落盘但过不了白名单的 repoUrl 会被 readFullSyncConfig 丢弃
203
+ // (绝不交给凭据注入/git 子进程),但**必须把成因带出去** —— 否则界面显示「未配置」,
204
+ // 用户永远看不出自己保存过的地址是被安全校验拒掉,而不是从来没存过。
205
+ const configIssues: SyncConfigIssue[] = []
206
+ const full = await readFullSyncConfig(syncDir, configIssues)
203
207
  const state = await loadSyncState(syncDir)
204
208
  const [cred, webdavCred] = await Promise.all([
205
209
  credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
@@ -231,6 +235,9 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
231
235
  configured: full !== null,
232
236
  transport,
233
237
  repoUrl: full?.git?.repoUrl,
238
+ // 被丢弃的通道配置成因(默认空数组:字段恒在,客户端不必判 undefined;
239
+ // 合法配置不产生幻影告警)。code 是机读 token,message 可直接展示。
240
+ configIssues,
234
241
  credentialConfigured: cred.configured,
235
242
  credentialWritable: cred.writable === true,
236
243
  // webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
@@ -302,10 +309,17 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
302
309
  writeJson(res, 400, { error: msg('sync.workdirRecloneNeedsConfirm') })
303
310
  return
304
311
  }
305
- const full = await readFullSyncConfig(syncDir)
312
+ const configIssues: SyncConfigIssue[] = []
313
+ const full = await readFullSyncConfig(syncDir, configIssues)
306
314
  const repoUrl = full?.git?.repoUrl ?? ''
307
315
  if (repoUrl === '') {
308
- writeJson(res, 400, { error: msg('sync.workdirNotConfigured') })
316
+ // SEC-03:区分「地址被安全校验拒掉」与「从来就没配」。两者都 400、都不重建,
317
+ // 但文案必须说实话 —— 说成「尚未配置」会让用户反复重填同一个被拒地址。
318
+ const rejected = configIssues.find((issue) => issue.channel === 'git')
319
+ writeJson(res, 400, {
320
+ error: rejected === undefined ? msg('sync.workdirNotConfigured') : rejected.message,
321
+ configIssues,
322
+ })
309
323
  return
310
324
  }
311
325
  const rebuilt = await syncWorkDir.rebuild(repoUrl, mode)
@@ -373,11 +387,15 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
373
387
  credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
374
388
  credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
375
389
  ])
376
- const cloud = await cloudChannelViews(await readFullSyncConfig(syncDir), credentials)
390
+ // SEC-03:写完就把落盘结果再读一遍(writeSyncConfig 可能因命名空间合并保留旧值),
391
+ // 成因而非「写成功」才是客户端要显示的真相;字段恒在(默认空数组)。
392
+ const configIssues: SyncConfigIssue[] = []
393
+ const cloud = await cloudChannelViews(await readFullSyncConfig(syncDir, configIssues), credentials)
377
394
  writeJson(res, 200, {
378
395
  ok: true,
379
396
  configured: true,
380
397
  transport: syncCfg.transport,
398
+ configIssues,
381
399
  credentialConfigured: cred.configured,
382
400
  // 通道判定消费单一来源 channelOf(B7 守卫扩到覆盖该谓词形态;此前是裸 isWebDavConfig 分支)
383
401
  webdav: channelOf(syncCfg) === 'webdav' ? describeWebDavSlot(syncCfg, webdavCred.configured) : undefined,
@@ -823,8 +841,9 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
823
841
  }
824
842
  const syncCfg = await prepareSync(body)
825
843
  const engine = makeSyncEngine(syncCfg)
826
- await withTimeout(engine.deleteSnapshot(snapshotId), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'))
827
- writeJson(res, 200, { ok: true, snapshotId })
844
+ const deleted = await withTimeout(engine.deleteSnapshot(snapshotId), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'))
845
+ // 远端孤儿 blob 回收被放弃时要能看见(RV1-F3):空数组 = 本轮什么都没被放弃。
846
+ writeJson(res, 200, { ok: true, snapshotId, warnings: deleted.warnings })
828
847
  } catch (error) {
829
848
  writeSyncRouteError(res, error)
830
849
  }