dsh-config-manager 0.2.3 → 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 (132) 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 +1256 -1185
  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 +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  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/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. package/src/utils/git-fast-forward.ts +8 -3
@@ -0,0 +1,38 @@
1
+ import { redact } from '../security/redaction.js';
2
+ /** 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。 */
3
+ export const MERGE_PREVIEW_TEXT_LIMIT = 2048;
4
+ /** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
5
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
6
+ /**
7
+ * 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。
8
+ *
9
+ * 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
10
+ * (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
11
+ */
12
+ export function finalizePreviewText(text) {
13
+ const masked = redact(text);
14
+ return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
15
+ }
16
+ /** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
17
+ export function previewTextOf(value) {
18
+ if (value === undefined)
19
+ return '';
20
+ if (typeof value === 'string')
21
+ return finalizePreviewText(value);
22
+ if (value instanceof Uint8Array) {
23
+ try {
24
+ return finalizePreviewText(STRICT_UTF8.decode(value));
25
+ }
26
+ catch {
27
+ // 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
28
+ return JSON.stringify({ binary: true, bytes: value.length });
29
+ }
30
+ }
31
+ try {
32
+ return finalizePreviewText(JSON.stringify(value) ?? '');
33
+ }
34
+ catch {
35
+ return finalizePreviewText(String(value));
36
+ }
37
+ }
38
+ //# sourceMappingURL=merge-preview-text.js.map
@@ -1,4 +1,5 @@
1
1
  import type { MsgFunc } from '../../core/msg-types.ts';
2
+ import type { GcSkipCode } from '../gc-skip.ts';
2
3
  import { SyncTransportError } from '../transport.ts';
3
4
  import type { SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
4
5
  import type { SigV4DialectId } from './sigv4.ts';
@@ -63,6 +64,10 @@ export declare class S3Transport implements SyncTransport {
63
64
  readonly type: string;
64
65
  private readonly target;
65
66
  private readonly o;
67
+ /** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
68
+ private gcSkip;
69
+ /** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
70
+ get lastGcSkip(): GcSkipCode | null;
66
71
  constructor(options: S3TransportOptions);
67
72
  /** 解析后的目标(只读;端点/region/寻址风格/方言;**不含任何密钥**)。 */
68
73
  get resolvedTarget(): S3Target;
@@ -129,6 +134,8 @@ export declare class S3Transport implements SyncTransport {
129
134
  /** 下载后把引用形态还原成文件分区(缺 blob → 硬失败,绝不降级为空分区) */
130
135
  private rehydrate;
131
136
  /** blob 仓 GC:逐份读仍存在的快照收集引用,删除无人引用且超保护窗的 blob;
132
- * 读坏任一份快照 → 本轮直接放弃(宁可留垃圾,不可删在用的) */
137
+ * 读不坏任何一份快照 → 正常回收,否则本轮整体放弃(宁可留垃圾,不可删在用的)。
138
+ * SYN-04(P1):**404 也放弃本轮** —— 条目在但文件 404 = 拿不到内容 = 无法证明「没人引用」,
139
+ * 按残集回收会删掉仍在用的 blob(与 WebDAV 同型,口径一致)。 */
133
140
  private gcBlobStore;
134
141
  }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { requestOnce } from '../../utils/proxy.js';
26
26
  import { parseJsonSafe } from '../../utils/json.js';
27
+ import { GC_SKIP_REFERENCE_MISSING, GC_SKIP_REFERENCE_UNREADABLE, GC_SKIP_SNAPSHOT_UNPARSABLE } from '../gc-skip.js';
27
28
  import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.js';
28
29
  import { BLOB_SECTIONS, gcBlobs, isBlobRefsSection, isFilesSectionLike, referencedBlobHashes, refsToSection, sectionToBlobRefs, } from '../blob-store.js';
29
30
  import { classifyHttpStatus, classifyNetworkErrorText, computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections, sectionsEqual, SyncTransportError, withSyncRetry, } from '../transport.js';
@@ -123,6 +124,12 @@ export class S3Transport {
123
124
  type;
124
125
  target;
125
126
  o;
127
+ /** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
128
+ gcSkip = null;
129
+ /** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
130
+ get lastGcSkip() {
131
+ return this.gcSkip;
132
+ }
126
133
  constructor(options) {
127
134
  // t79:宿主传进来的 msg(core 目录)**不包含** sync.s3.* 键,直接用会显示裸键名 —— 合并本地目录。
128
135
  const msg = composeMsg(options.msg, s3Msg);
@@ -593,24 +600,31 @@ export class S3Transport {
593
600
  return { ...snapshot, sections: next };
594
601
  }
595
602
  /** blob 仓 GC:逐份读仍存在的快照收集引用,删除无人引用且超保护窗的 blob;
596
- * 读坏任一份快照 → 本轮直接放弃(宁可留垃圾,不可删在用的) */
603
+ * 读不坏任何一份快照 → 正常回收,否则本轮整体放弃(宁可留垃圾,不可删在用的)。
604
+ * SYN-04(P1):**404 也放弃本轮** —— 条目在但文件 404 = 拿不到内容 = 无法证明「没人引用」,
605
+ * 按残集回收会删掉仍在用的 blob(与 WebDAV 同型,口径一致)。 */
597
606
  async gcBlobStore(secret, remaining) {
607
+ this.gcSkip = null;
598
608
  const index = await this.readBlobIndex(secret);
599
609
  if (Object.keys(index).length === 0)
600
610
  return;
601
611
  const referenced = new Set();
602
612
  for (const meta of remaining) {
603
613
  const sent = await this.send('GET', snapshotKey(this.target.prefix, meta.id), { secret });
604
- // 条目在但文件 404 ⇒ 不可能引用 blob(安全跳过);其它失败 ⇒ 读不出来 ≠ 没引用,本轮放弃。
605
- if (sent.res.status === 404)
606
- continue;
607
- if (!sent.res.ok)
614
+ if (sent.res.status === 404) {
615
+ this.gcSkip = GC_SKIP_REFERENCE_MISSING;
608
616
  return;
617
+ }
618
+ if (!sent.res.ok) {
619
+ this.gcSkip = GC_SKIP_REFERENCE_UNREADABLE;
620
+ return;
621
+ }
609
622
  let snap;
610
623
  try {
611
624
  snap = deserializeSnapshot(await sent.res.text());
612
625
  }
613
626
  catch {
627
+ this.gcSkip = GC_SKIP_SNAPSHOT_UNPARSABLE;
614
628
  return;
615
629
  }
616
630
  const plain = snap.sections;
@@ -1,3 +1,5 @@
1
+ import type { SectionData } from '../schema/types.ts';
2
+ import type { SyncSnapshotMeta, SyncTransport } from './transport.ts';
1
3
  /** 会话单元 id 前缀(与 SessionsAdapter.unitIdOf 同口径:`sessions:<projectKey>/<sessionId>`)。 */
2
4
  export declare const SESSION_UNIT_PREFIX = "sessions:";
3
5
  /** 墓碑上限(FIFO 保留最新 N 条):防止 manifest 无限膨胀(真机会话库 ~660 条,5000 足够宽裕)。 */
@@ -37,3 +39,54 @@ export declare function stripTombstonedUnits(section: unknown, tombstones: reado
37
39
  export declare function sessionUnitIdsOfSection(section: unknown): string[];
38
40
  /** 内容哈希(用于报告/去重;调用方需要时可对单元清单取指纹)。 */
39
41
  export declare function unitSetFingerprint(units: readonly string[]): string;
42
+ /**
43
+ * 一次远端 list() 的读取结果。
44
+ *
45
+ * `metas` 为空**且**无 `reason` 才代表「远端确实为空」;有 reason = 读不到(按「看不到远端」
46
+ * 处理,调用方告警后继续,绝不把它当成「远端没东西」而误判)。
47
+ */
48
+ export interface RemoteSnapshotIndex {
49
+ metas: SyncSnapshotMeta[];
50
+ reason?: string;
51
+ }
52
+ /** 读一次远端快照索引(只读、零写入)。push 的「是否被推进」判定与墓碑继承共用这一份。 */
53
+ export declare function listRemoteSnapshotIndex(transport: Pick<SyncTransport, 'list'>): Promise<RemoteSnapshotIndex>;
54
+ /**
55
+ * 远端最新快照携带的会话删除墓碑(去重、去空、保序)。
56
+ *
57
+ * 只取**最新一份**:manifest.deletedSessions 是累积清单(见文件头),旧快照的墓碑必然被后续
58
+ * 快照包含;把整段历史并起来只会让「已经在别处撤销的墓碑」复活。
59
+ * 远端为空 / 老快照缺字段 → 空数组(行为与改造前逐字一致:不继承任何墓碑)。
60
+ */
61
+ export declare function remoteTombstonesOf(metas: readonly SyncSnapshotMeta[]): string[];
62
+ /** 一次 push 的会话簿记结果(manifest / baseline / 告警共用这一份)。 */
63
+ export interface SessionBookkeepingResult {
64
+ /** false = 本次没有可用的会话枚举(没推 sessions / 适配器不支持 / 枚举失败或为空) */
65
+ tracked: boolean;
66
+ sessionUnits: string[];
67
+ tombstones: string[];
68
+ /** 本次**新**发现的删除(之前推过、这次本机没了) */
69
+ deletedNow: string[];
70
+ /** 从远端继承来的墓碑条数(本机此前没有、且本机也没有实体) */
71
+ inherited: number;
72
+ }
73
+ /**
74
+ * 墓碑集合的唯一计算点(本机删除 + 远端继承合进同一条累积链)。
75
+ *
76
+ * 去重、FIFO 上限、以及「本机还有实体 ⇒ 撤销墓碑」这条不变量对继承来的墓碑同样适用:
77
+ * 另一台设备删了、本机仍有 → 用户在本机显然没删,绝不把它记成本机删除。
78
+ */
79
+ export declare function planSessionBookkeeping(args: {
80
+ previousUnits: readonly string[];
81
+ previousTombstones: readonly string[];
82
+ localUnits: readonly string[];
83
+ exportedSessions: SectionData | undefined;
84
+ remoteTombstones: readonly string[];
85
+ }): Omit<SessionBookkeepingResult, 'tracked'>;
86
+ /** 按墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。只动载荷,不动本机数据。 */
87
+ export declare function stripTombstonedSessionsInPlace<P extends {
88
+ manifest: {
89
+ deletedSessions?: readonly string[];
90
+ };
91
+ sections: unknown;
92
+ }>(snapshot: P): string[];
@@ -18,6 +18,7 @@
18
18
  * 本次不实现(保留信息可见,不代用户删数据)。
19
19
  */
20
20
  import { sha256Hex } from '../utils/hashing.js';
21
+ import { isEncryptedSections } from './transport.js';
21
22
  /** 会话单元 id 前缀(与 SessionsAdapter.unitIdOf 同口径:`sessions:<projectKey>/<sessionId>`)。 */
22
23
  export const SESSION_UNIT_PREFIX = 'sessions:';
23
24
  /** 墓碑上限(FIFO 保留最新 N 条):防止 manifest 无限膨胀(真机会话库 ~660 条,5000 足够宽裕)。 */
@@ -122,4 +123,55 @@ export function sessionUnitIdsOfSection(section) {
122
123
  export function unitSetFingerprint(units) {
123
124
  return sha256Hex([...units].sort().join('\n'));
124
125
  }
126
+ /** 读一次远端快照索引(只读、零写入)。push 的「是否被推进」判定与墓碑继承共用这一份。 */
127
+ export async function listRemoteSnapshotIndex(transport) {
128
+ try {
129
+ return { metas: await transport.list() };
130
+ }
131
+ catch (error) {
132
+ return { metas: [], reason: error instanceof Error ? error.message : String(error) };
133
+ }
134
+ }
135
+ /**
136
+ * 远端最新快照携带的会话删除墓碑(去重、去空、保序)。
137
+ *
138
+ * 只取**最新一份**:manifest.deletedSessions 是累积清单(见文件头),旧快照的墓碑必然被后续
139
+ * 快照包含;把整段历史并起来只会让「已经在别处撤销的墓碑」复活。
140
+ * 远端为空 / 老快照缺字段 → 空数组(行为与改造前逐字一致:不继承任何墓碑)。
141
+ */
142
+ export function remoteTombstonesOf(metas) {
143
+ const latest = metas.length === 0 ? undefined : metas[metas.length - 1];
144
+ const list = latest?.manifest.deletedSessions ?? [];
145
+ return [...new Set(list.filter((id) => typeof id === 'string' && id !== ''))];
146
+ }
147
+ /**
148
+ * 墓碑集合的唯一计算点(本机删除 + 远端继承合进同一条累积链)。
149
+ *
150
+ * 去重、FIFO 上限、以及「本机还有实体 ⇒ 撤销墓碑」这条不变量对继承来的墓碑同样适用:
151
+ * 另一台设备删了、本机仍有 → 用户在本机显然没删,绝不把它记成本机删除。
152
+ */
153
+ export function planSessionBookkeeping(args) {
154
+ const known = args.previousTombstones;
155
+ const knownSet = new Set(known);
156
+ const localSet = new Set(args.localUnits);
157
+ const inherited = args.remoteTombstones.filter((id) => !knownSet.has(id) && !localSet.has(id)).length;
158
+ const { tombstones, deletedNow } = nextSessionTombstones({
159
+ previousUnits: [...args.previousUnits],
160
+ localUnits: [...args.localUnits],
161
+ previousTombstones: [...known, ...args.remoteTombstones],
162
+ });
163
+ return { sessionUnits: sessionUnitIdsOfSection(args.exportedSessions), tombstones, deletedNow, inherited };
164
+ }
165
+ /** 按墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。只动载荷,不动本机数据。 */
166
+ export function stripTombstonedSessionsInPlace(snapshot) {
167
+ const tombstones = snapshot.manifest.deletedSessions ?? [];
168
+ if (tombstones.length === 0 || isEncryptedSections(snapshot.sections))
169
+ return [];
170
+ const plain = snapshot.sections;
171
+ const stripped = stripTombstonedUnits(plain['sessions'], tombstones);
172
+ if (stripped.removed.length === 0)
173
+ return [];
174
+ plain['sessions'] = stripped.section;
175
+ return stripped.removed;
176
+ }
125
177
  //# sourceMappingURL=session-tombstones.js.map
@@ -98,18 +98,21 @@ export declare function isGistConfig(cfg: SyncConfig): cfg is Extract<SyncConfig
98
98
  * 读取同步通道配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
99
99
  * 兼容旧文件:缺 schemaVersion 字段视为 v1(git 通道)。
100
100
  * 恒返回 schemaVersion=2 的规范形态(v1 读取时归一为 git)。
101
+ *
102
+ * `issues` 可选:传数组时,被丢弃的 git repoUrl(白名单校验不通过)以结构化成因追加进去,
103
+ * 供 UI/日志如实呈现(不静默吞掉)。
101
104
  */
102
- export declare function readSyncConfig(dir: string): Promise<SyncConfig | null>;
105
+ export declare function readSyncConfig(dir: string, issues?: SyncConfigIssue[]): Promise<SyncConfig | null>;
103
106
  /**
104
107
  * 读取完整的双命名空间配置(供 status 路由回填另一通道的 repoUrl/url)。
105
108
  * 文件不存在/损坏/无任何通道配置 → null(视为未配置)。
106
109
  */
107
- export declare function readFullSyncConfig(dir: string): Promise<FullSyncConfig | null>;
110
+ export declare function readFullSyncConfig(dir: string, issues?: SyncConfigIssue[]): Promise<FullSyncConfig | null>;
108
111
  /**
109
112
  * 读取指定通道的同步通道配置(供自动同步调度器按通道运行)。
110
113
  * 从完整双命名空间配置取对应通道构造可辨识联合 SyncConfig;该通道未配置 → null。
111
114
  */
112
- export declare function readSyncConfigFor(dir: string, channel: SyncTransportType): Promise<SyncConfig | null>;
115
+ export declare function readSyncConfigFor(dir: string, channel: SyncTransportType, issues?: SyncConfigIssue[]): Promise<SyncConfig | null>;
113
116
  /**
114
117
  * 保存同步通道配置(自动创建目录;恒写 schemaVersion=3 双命名空间)。
115
118
  * - 写入当前通道的命名空间(git/webdav);
@@ -142,10 +145,57 @@ export interface ClearSyncChannelResult {
142
145
  * 它们分别住在 DSH credentials 与 autosync.json,由路由层显式清除(本模块只碰 sync-config.json)。
143
146
  */
144
147
  export declare function clearSyncChannel(dir: string, channel: SyncTransportType): Promise<ClearSyncChannelResult>;
148
+ /** repoUrl 形态:网络地址(http/https/ssh,含 scp 式)或本机路径 */
149
+ export type RepoUrlKind = 'http' | 'https' | 'ssh' | 'local';
150
+ /**
151
+ * 已落盘 repoUrl 校验失败的**结构化**结果(SEC-01 读侧)。
152
+ * 读取侧不能只是返回 null:调用方要能如实告诉用户「你的配置被丢弃了,原因是地址不合法」。
153
+ */
154
+ export interface SyncConfigIssue {
155
+ code: 'repo-url-invalid';
156
+ channel: SyncTransportType;
157
+ message: string;
158
+ }
159
+ /**
160
+ * repoUrl 形态分类;null = 不认识的形态。
161
+ *
162
+ * 为什么要分类而不是只做正则:git 对同一个「地址」支持 http(s)、ssh、scp 式与本机路径,
163
+ * 而这三类的安全含义完全不同(网络地址要防 SSRF,本机路径要防 argv 注入)。先判形态,
164
+ * 再按形态分别收紧,比一堆互相打架的正则清楚。
165
+ */
166
+ export declare function classifyRepoUrl(repoUrl: string): {
167
+ kind: RepoUrlKind;
168
+ host: string;
169
+ } | null;
170
+ /**
171
+ * 环回 / 私网 / 链路本地 / 未指定地址(含云元数据 169.254.169.254)判定。
172
+ * SEC-03:这类地址让「客户端」去访问内网或本机端口,等于给 SSRF 开一扇门。
173
+ *
174
+ * **已知行为变更(有意,非副作用)**:SEC-02/03 之前 `https://192.168.1.10/…`、`http://127.0.0.1:3000/…`、
175
+ * `http://nas.local/…` 这类地址可以被接受。收紧后一律拒绝 —— 代价是「拿家里 NAS / 局域网 git 服务器
176
+ * 当同步目标」这条路走不通,收益是「包里的 repoUrl 无法把家目录配置推给内网监听者或云元数据端点」。
177
+ * 取舍判据:本插件的同步目标是「用户自己的远端仓库」,公网 http(s)/ssh 已覆盖该用途;本机目录仓库
178
+ * 仍走 `local` 分支(不由本函数判定)。真要支持内网目标,应做成显式开关 + 用户逐目标确认,
179
+ * 不能靠「地址字符串看起来像内网就放行」——那正是攻击者可控的判据。
180
+ */
181
+ export declare function isPrivateRepoHost(host: string): boolean;
182
+ /**
183
+ * 文案里回显地址用:剥掉 userinfo(token 可能就藏在这一段),host/path 原样保留。
184
+ *
185
+ * 为什么不整串换成 `[REPO_URL]` 占位符:这里拒绝的是**用户自己刚填的**地址,他需要认出
186
+ * 「被拒的是哪一个」才能改;剥掉 `user:pass@` 之后剩下的部分正是修复所需的定位信息。
187
+ * 为什么解析失败也要剥:`https://user:tok@` 这类半截地址 `new URL` 会抛,但前缀照样是凭据。
188
+ */
189
+ export declare function sanitizeRepoUrlForMessage(url: string): string;
145
190
  /**
146
191
  * 仓库地址合法性校验(返回错误消息;null = 合法)。
147
- * 安全约束:token 永不拼入 repoUrl —— http(s) 地址带 userinfo(username[:password]@)直接拒绝,
148
- * 引导用户把 token 放凭据字段(DSH credentials),避免 token 经 URL 泄漏进 git 历史/日志。
192
+ *
193
+ * 白名单口径(SEC-02/03/12,写入侧与读取侧共用同一份实现):
194
+ * - 只认 http(s)/ssh/scp 式网络地址与本机路径;file://、ext::、ftp://、git:// 一律拒绝;
195
+ * - 整串以 '-' 开头拒绝(git argv 位置参数以 '-' 开头会被当选项,可执行任意命令);
196
+ * - 网络地址拒绝环回/私网/链路本地主机(SSRF)—— 文案单独一条(sync.configPrivateHost),
197
+ * 不能复用「无法解析」:用户看到「解析不了」只会反复改写法,实际原因是地址指向内网;
198
+ * - 带 scheme 的地址一律拒绝 userinfo(username[:password]@)—— token 只许放凭据字段。
149
199
  */
150
200
  export declare function validateRepoUrl(repoUrl: string, msg?: MsgFunc): string | null;
151
201
  /**
@@ -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
  /**