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
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 本轮 blob GC 的放弃码(SYN-04)。**机器可判定**,文案由上层按码翻译(字典键由 Lead 统一加):
3
+ * 通道没有 logger,`delete()` 还会吞掉 GC 抛出的异常,所以「本轮为什么没回收」只能靠这个码如实外露。
4
+ * 三态同义:拿不到引用者内容 = 无法证明「没人引用」⇒ 本轮整轮放弃,绝不按残集删除。
5
+ *
6
+ * 单一来源:webdav / s3 两个通道都从这里导入(此前各写一份同名常量,改一侧不炸另一侧 =
7
+ * 静默漂移;本模块不 import 任何 transport,所以不存在环)。
8
+ */
9
+ export const GC_SKIP_REFERENCE_MISSING = 'gc-skip-reference-missing';
10
+ export const GC_SKIP_REFERENCE_UNREADABLE = 'gc-skip-reference-unreadable';
11
+ export const GC_SKIP_SNAPSHOT_UNPARSABLE = 'gc-skip-snapshot-unparsable';
12
+ /**
13
+ * SYN-04b:通道「本轮 GC 放弃码」的读取与冒泡(从 sync-engine.ts 下沉,行为逐字不变)。
14
+ *
15
+ * 为什么放在独立模块:SYN-04 之后 webdav/s3 遇到「引用快照 404 / 读不出」会**放弃本轮 blob GC**
16
+ * (绝不在「证明不了没人引用」时删 blob),而通道层没有 logger、delete() 又把 GC 异常吃掉
17
+ * ⇒ 放弃只能靠 transport 的只读字段外露。字段形态是 getter(见 webdav/s3 transport 的
18
+ * `get lastGcSkip()`),这里按 unknown 读、只接受非空字符串,getter 抛错也不影响 push。
19
+ *
20
+ * 判据(缺一不可,全部当「没有放弃码」):字段缺失 / null / 非字符串 / 空串 / getter 抛错。
21
+ * 读不到 ≠ 没有放弃:通道没这个能力时本来就不该冒泡告警,所以两者在调用侧合并处理。
22
+ */
23
+ export function readGcSkipCode(transport) {
24
+ try {
25
+ const raw = transport?.lastGcSkip;
26
+ return typeof raw === 'string' && raw !== '' ? raw : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * SYN-04b:放弃码 → 一条 warning 文本。
34
+ *
35
+ * 码必须同时以**明文**出现:core 目录的翻译器对未知键回退键名本身({code} 插值随之丢失),
36
+ * 而文案键由上层统一补字典 —— 在补键之前,只发出裸键名等于「报了但看不出来」,日志也无法 grep。
37
+ */
38
+ export function gcSkipWarning(msg, code) {
39
+ const text = msg('sync.gcSkipped', { code });
40
+ return text.includes(code) ? text : text + ': ' + code;
41
+ }
42
+ /**
43
+ * SYN-04b:把通道的 GC 放弃码冒泡成一条 warning(`state` 保证同一轮只报一次)。
44
+ *
45
+ * 语义边界:放弃 GC **不是** push 失败 —— 快照本身已上传/已删除,只是这一轮的孤儿 blob
46
+ * 没被回收(磁盘/远端会多留几份文件)。所以它进 warnings、不进 ok,与 prune 既有语义一致。
47
+ */
48
+ export function reportGcSkipOnce(transport, msg, warnings, state) {
49
+ if (state.done)
50
+ return;
51
+ const code = readGcSkipCode(transport);
52
+ if (code === null)
53
+ return;
54
+ state.done = true;
55
+ warnings.push(gcSkipWarning(msg, code));
56
+ }
57
+ //# sourceMappingURL=gc-skip.js.map
@@ -109,6 +109,8 @@ export declare class GitTransport implements SyncTransport {
109
109
  */
110
110
  private readErrors;
111
111
  private readonly msg;
112
+ /** repoUrl 是否为网络地址(本地目录仓库 == false):决定要不要额外禁 file 传输(SEC-12)。 */
113
+ private readonly networkRepo;
112
114
  constructor(options: GitTransportOptions);
113
115
  /** 最近一次 checkIsPrivate() 的结果;null = 尚未检查 */
114
116
  get isPrivateHint(): boolean | null;
@@ -283,6 +285,14 @@ export declare class GitTransport implements SyncTransport {
283
285
  private repoHost;
284
286
  /** 读取一次 token(错误消息脱敏用);非 http(s) 场景返回空 */
285
287
  private readTokenOnce;
288
+ /**
289
+ * 回传体里的地址(工作副本自检的 origin / pushUrl)脱敏:只剥「凭据段」,其余原样保留。
290
+ *
291
+ * 为什么不整串换占位符:状态面板要显示「工作副本现在指向哪个远端」,用户靠 host/path 判断是否
292
+ * 已跟随配置。为什么不一律剥 userinfo:`ssh://git@host` 里的 `git` 是 ssh 登录名(标准写法),
293
+ * 剥掉会让用户认不出自己的远端 —— 与 validateRepoUrl 的口径保持一致(只有 password 位是凭据)。
294
+ */
295
+ private bareUrl;
286
296
  /** 错误/日志脱敏:token(原文与 URL 编码两种形态)与 repoUrl 一律替换 */
287
297
  private mask;
288
298
  /**
@@ -26,6 +26,7 @@ import { createSnapshotFs, joinFs } from '../fs.js';
26
26
  import { readSnapshotFromDir, SNAPSHOT_BLOB_REFS_SUFFIX, SNAPSHOT_KEEP_CONTENT, SNAPSHOT_KEEP_FILE, SNAPSHOT_MANIFEST_FILE, writeSnapshotToDir } from '../layout.js';
27
27
  import { BLOB_SECTIONS, gcBlobs, referencedBlobHashes } from '../blob-store.js';
28
28
  import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.js';
29
+ import { classifyRepoUrl, isPrivateRepoHost } from '../sync-config.js';
29
30
  import { classifyNetworkErrorText, computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections, SyncTransportError, withSyncRetry, } from '../transport.js';
30
31
  import { parseJsonSafe } from '../../utils/json.js';
31
32
  import { atomicWriteFile } from '../../utils/atomic-write.js';
@@ -111,6 +112,19 @@ const SYNC_ROOT_DIRS = [...ADOPT_ROOT_ALLOW];
111
112
  */
112
113
  const NETWORK_SUBCOMMANDS = new Set(['clone', 'fetch', 'push', 'ls-remote', 'pull']);
113
114
  const LOW_SPEED_ARGS = ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=30'];
115
+ /**
116
+ * 协议护栏(SEC-12):**所有** git 子进程恒带,与「这条命令走不走网络」无关。
117
+ *
118
+ * 为什么恒带而不是只给网络命令:危险的不只是远端协议本身 —— `ext::sh -c ...` / `file://`
119
+ * 这类地址在 clone/fetch 之外还能经 `remote set-url`、配置重载等路径被触发,而本插件的 repoUrl
120
+ * 来自用户可编辑的 sync-config.json 与可导入的配置包。把 `ext` 与 `file` 传输整体关掉,
121
+ * 就等于「repoUrl 无论被改成什么,都只能走 http/https/ssh 三种真协议」。
122
+ */
123
+ const PROTOCOL_GUARD_ARGS = ['-c', 'protocol.ext.allow=never'];
124
+ /** 网络仓库额外禁 file 传输(本地目录仓库是显式支持的用法,不能一刀切禁掉)。 */
125
+ const FILE_PROTOCOL_GUARD_ARGS = ['-c', 'protocol.file.allow=never'];
126
+ /** 报错/日志里回显的地址占位符:不带真实 repoUrl(脱敏由上层的 mask() 负责)。 */
127
+ const REPO_URL_PLACEHOLDER = '[REPO_URL]';
114
128
  const DEFAULT_AUTHOR = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
115
129
  const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
116
130
  /** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
@@ -270,6 +284,8 @@ export class GitTransport {
270
284
  */
271
285
  readErrors = [];
272
286
  msg;
287
+ /** repoUrl 是否为网络地址(本地目录仓库 == false):决定要不要额外禁 file 传输(SEC-12)。 */
288
+ networkRepo;
273
289
  constructor(options) {
274
290
  if (typeof options.repoUrl !== 'string' || options.repoUrl.length === 0) {
275
291
  throw new GitTransportError(zhMsg('sync.git.repoUrlRequired'));
@@ -282,6 +298,16 @@ export class GitTransport {
282
298
  throw new GitTransportError(zhMsg('sync.git.credentialsRequired'));
283
299
  }
284
300
  this.msg = options.msg ?? zhMsg;
301
+ // SEC-12:repoUrl 来自用户可编辑配置与可导入配置包 —— 构造期就收成「只能是真网络地址或本地目录」,
302
+ // 环回/私网/链路本地一律拒(把家目录配置推到 localhost 上的抓取器是最省事的外泄方式)。
303
+ // **已知行为变更**:局域网 NAS / 内网 git 服务器作为同步目标此后会被拒绝(见 sync-config.ts
304
+ // 的 isPrivateRepoHost 注释里的取舍说明),这是有意的安全收紧,不是回归。
305
+ // 地址本体不回显进错误(占位符代替),脱敏仍由 mask() 兜底。
306
+ const repoShape = classifyRepoUrl(options.repoUrl);
307
+ if (repoShape === null || (repoShape.kind !== 'local' && isPrivateRepoHost(repoShape.host))) {
308
+ throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: REPO_URL_PLACEHOLDER }));
309
+ }
310
+ this.networkRepo = repoShape.kind !== 'local';
285
311
  this.o = {
286
312
  repoUrl: options.repoUrl,
287
313
  workDir: options.workDir,
@@ -494,12 +520,12 @@ export class GitTransport {
494
520
  async checkIsPrivate() {
495
521
  if (this.privateHint !== null)
496
522
  return this.privateHint;
497
- const anon = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
523
+ const anon = await this.runGit(['ls-remote', '--', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
498
524
  if (anon.code === 0) {
499
525
  this.privateHint = false;
500
526
  return false;
501
527
  }
502
- const authed = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
528
+ const authed = await this.runGit(['ls-remote', '--', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
503
529
  if (authed.code === 0) {
504
530
  this.privateHint = true;
505
531
  return true;
@@ -618,7 +644,7 @@ export class GitTransport {
618
644
  async cloneIntoWorkDir() {
619
645
  const cwd = this.o.workDir;
620
646
  this.partialClone = null; // reclone 后重新判定(配置可能变)
621
- const filteredArgs = ['clone', '--filter=blob:none', '--no-checkout', this.o.repoUrl, '.'];
647
+ const filteredArgs = ['clone', '--filter=blob:none', '--no-checkout', '--', this.o.repoUrl, '.'];
622
648
  const filtered = await this.runGit(filteredArgs, { cwd, withCredential: true, allowNonZero: true });
623
649
  if (filtered.code !== 0) {
624
650
  if (!UNSUPPORTED_GIT_OPTION_RE.test(filtered.stderr + ' ' + filtered.stdout)) {
@@ -627,7 +653,7 @@ export class GitTransport {
627
653
  }
628
654
  // 老 git:回落全量 clone(全量 clone 自带 checkout)
629
655
  await clearDirContents(cwd);
630
- await this.runGit(['clone', this.o.repoUrl, '.'], { cwd, withCredential: true });
656
+ await this.runGit(['clone', '--', this.o.repoUrl, '.'], { cwd, withCredential: true });
631
657
  return;
632
658
  }
633
659
  const branch = await this.currentBranch();
@@ -697,16 +723,16 @@ export class GitTransport {
697
723
  const current = await this.runGit(['remote', 'get-url', 'origin'], { allowNonZero: true });
698
724
  const origin = current.code === 0 ? current.stdout.trim() : '';
699
725
  if (current.code !== 0) {
700
- await this.runGit(['remote', 'add', 'origin', this.o.repoUrl]);
726
+ await this.runGit(['remote', 'add', 'origin', '--', this.o.repoUrl]);
701
727
  }
702
728
  else if (origin !== '' && origin !== this.o.repoUrl) {
703
- await this.runGit(['remote', 'set-url', 'origin', this.o.repoUrl]);
729
+ await this.runGit(['remote', 'set-url', 'origin', '--', this.o.repoUrl]);
704
730
  this.partialClone = null; // 换了远端 ⇒ promisor 判定失效(issue #122)
705
731
  }
706
732
  const push = await this.runGit(['remote', 'get-url', '--push', 'origin'], { allowNonZero: true });
707
733
  const pushUrl = push.code === 0 ? push.stdout.trim() : '';
708
734
  if (pushUrl !== '' && pushUrl !== this.o.repoUrl) {
709
- await this.runGit(['remote', 'set-url', '--push', 'origin', this.o.repoUrl]);
735
+ await this.runGit(['remote', 'set-url', '--push', 'origin', '--', this.o.repoUrl]);
710
736
  }
711
737
  }
712
738
  /**
@@ -805,8 +831,9 @@ export class GitTransport {
805
831
  return {
806
832
  path: this.o.workDir,
807
833
  exists: true,
808
- origin: origin === '' ? null : origin,
809
- pushUrl: pushRaw === '' ? null : pushRaw,
834
+ // RV1-F8:这两个字段会进 GET /sync/status 的响应 → 剥掉可能存在的 userinfo token
835
+ origin: origin === '' ? null : this.bareUrl(origin),
836
+ pushUrl: pushRaw === '' ? null : this.bareUrl(pushRaw),
810
837
  // 读不出 origin(null)→ 不算 match(状态接口据此提示「工作副本地址未知」)
811
838
  match: origin === this.o.repoUrl && (pushRaw === '' || pushRaw === this.o.repoUrl),
812
839
  ahead,
@@ -1018,6 +1045,10 @@ export class GitTransport {
1018
1045
  const cwd = opts.cwd === undefined ? this.o.workDir : opts.cwd;
1019
1046
  // 网络子命令一律带低速上限:真卡住在 30 s 内明确失败,而不是耗满 timeoutMs(issue #122)
1020
1047
  const networkArgs = NETWORK_SUBCOMMANDS.has(args[0] ?? '') ? [...LOW_SPEED_ARGS] : [];
1048
+ // SEC-12:协议护栏恒带(见 PROTOCOL_GUARD_ARGS);网络仓库再额外禁 file 传输。
1049
+ const guardArgs = this.networkRepo
1050
+ ? [...PROTOCOL_GUARD_ARGS, ...FILE_PROTOCOL_GUARD_ARGS]
1051
+ : [...PROTOCOL_GUARD_ARGS];
1021
1052
  let extra = [];
1022
1053
  let token = null;
1023
1054
  let cleanup = null;
@@ -1037,7 +1068,7 @@ export class GitTransport {
1037
1068
  // 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
1038
1069
  const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
1039
1070
  try {
1040
- let result = await this.o.exec(this.o.gitBin, [...extra, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs });
1071
+ let result = await this.o.exec(this.o.gitBin, [...extra, ...guardArgs, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs });
1041
1072
  // issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
1042
1073
  // `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
1043
1074
  // 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
@@ -1048,7 +1079,7 @@ export class GitTransport {
1048
1079
  if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
1049
1080
  if (args[0] === 'clone')
1050
1081
  await clearDirContents(cwd);
1051
- result = await this.o.exec(this.o.gitBin, [...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
1082
+ result = await this.o.exec(this.o.gitBin, [...guardArgs, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
1052
1083
  }
1053
1084
  }
1054
1085
  if (result.code !== 0 && !opts.allowNonZero) {
@@ -1061,7 +1092,9 @@ export class GitTransport {
1061
1092
  throw err;
1062
1093
  // 注入的 exec 实现可能直接抛错(默认实现只返回非零 code)——同样归一为带分类的传输层错误
1063
1094
  const detail = err instanceof Error ? err.message : String(err);
1064
- throw new GitTransportError(this.msg('sync.git.cmdFailed', { args: args.join(' '), code: 'exec', err: this.mask(detail, token) }), { ...classifyNetworkErrorText(detail), cause: err });
1095
+ throw new GitTransportError(
1096
+ // argv 里含 repoUrl 原文(clone/ls-remote/remote set-url -- <url>)→ 必须经 mask 才进文案
1097
+ this.msg('sync.git.cmdFailed', { args: this.mask(args.join(' '), token), code: 'exec', err: this.mask(detail, token) }), { ...classifyNetworkErrorText(detail), cause: err });
1065
1098
  }
1066
1099
  finally {
1067
1100
  if (cleanup)
@@ -1092,7 +1125,7 @@ export class GitTransport {
1092
1125
  ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
1093
1126
  : args[0] === 'clone' && classified.kind === 'auth'
1094
1127
  ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
1095
- : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
1128
+ : this.msg('sync.git.cmdFailed', { args: this.mask(args.join(' '), token), code: String(result.code), err: this.mask(result.stderr, token) });
1096
1129
  // 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
1097
1130
  // notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
1098
1131
  throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
@@ -1140,7 +1173,8 @@ export class GitTransport {
1140
1173
  return u.port ? `${u.hostname}:${u.port}` : u.hostname;
1141
1174
  }
1142
1175
  catch {
1143
- throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: this.o.repoUrl }));
1176
+ // RV1-F8:回显一律占位符 —— repoUrl 可能带 userinfo token,错误消息会进日志/响应/issue 截图
1177
+ throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: REPO_URL_PLACEHOLDER }));
1144
1178
  }
1145
1179
  }
1146
1180
  /** 读取一次 token(错误消息脱敏用);非 http(s) 场景返回空 */
@@ -1154,6 +1188,30 @@ export class GitTransport {
1154
1188
  return null;
1155
1189
  }
1156
1190
  }
1191
+ /**
1192
+ * 回传体里的地址(工作副本自检的 origin / pushUrl)脱敏:只剥「凭据段」,其余原样保留。
1193
+ *
1194
+ * 为什么不整串换占位符:状态面板要显示「工作副本现在指向哪个远端」,用户靠 host/path 判断是否
1195
+ * 已跟随配置。为什么不一律剥 userinfo:`ssh://git@host` 里的 `git` 是 ssh 登录名(标准写法),
1196
+ * 剥掉会让用户认不出自己的远端 —— 与 validateRepoUrl 的口径保持一致(只有 password 位是凭据)。
1197
+ */
1198
+ bareUrl(url) {
1199
+ try {
1200
+ const parsed = new URL(url);
1201
+ const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
1202
+ const keepSshUser = scheme === 'ssh' && parsed.password === '';
1203
+ const dirty = parsed.password !== '' || (parsed.username !== '' && !keepSshUser);
1204
+ if (!dirty)
1205
+ return url;
1206
+ parsed.username = '';
1207
+ parsed.password = '';
1208
+ return parsed.toString();
1209
+ }
1210
+ catch {
1211
+ // 解析不了(scp 式 `git@host:path` 或本机路径):`git@` 是登录名不是凭据,原样返回
1212
+ return url;
1213
+ }
1214
+ }
1157
1215
  /** 错误/日志脱敏:token(原文与 URL 编码两种形态)与 repoUrl 一律替换 */
1158
1216
  mask(text, token) {
1159
1217
  let out = text.split(this.o.repoUrl).join('[REPO_URL]');
@@ -0,0 +1,11 @@
1
+ /** 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。 */
2
+ export declare const MERGE_PREVIEW_TEXT_LIMIT = 2048;
3
+ /**
4
+ * 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。
5
+ *
6
+ * 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
7
+ * (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
8
+ */
9
+ export declare function finalizePreviewText(text: string): string;
10
+ /** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
11
+ export declare function previewTextOf(value: unknown): string;
@@ -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
  /**