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
@@ -24,7 +24,7 @@ import {
24
24
  readSyncConfig, readFullSyncConfig, readSyncConfigFor, writeSyncConfig, isGitConfig, isWebDavConfig,
25
25
  isS3Config, isGistConfig, listConfiguredChannels, readActiveS3Provider,
26
26
  SYNC_CONFIG_FILE, SYNC_CONFIG_SCHEMA_VERSION, SYNC_CONFIG_SUPPORTED_VERSIONS, CLOUD_SYNC_CONFIG_FILE,
27
- validateWebDavUrl, type SyncConfig,
27
+ validateWebDavUrl, validateRepoUrl, type SyncConfig, type SyncConfigIssue,
28
28
  SYNC_CHANNELS, channelOf, channelMap, isSyncTransportType, parseSyncChannel, clearSyncChannel,
29
29
  S3_COMPAT_PROVIDERS, CLOUD_SYNC_PROVIDERS, cloudSecretRef,
30
30
  } from './sync-config.ts';
@@ -739,3 +739,172 @@ test('t88 跨侧常量守卫:S3 兼容商清单与云端点密钥槽位引用
739
739
  'client 侧必须 re-export 共享派生函数(不得自己再实现一遍)',
740
740
  );
741
741
  });
742
+ /* ------------------------------------------------------------------------------------------------
743
+ * SEC-02 / SEC-03 / SEC-12:repoUrl 白名单(审计 §安全 P0 链)
744
+ *
745
+ * 事实:repoUrl 直接进 git 子进程 argv,且 http(s) 通道会把凭据注入到它的 host 上。原实现只拒
746
+ * 空白与 userinfo,于是「file:// 任意本机仓」「ext::sh -c …」「-c core.pager=…」、环回/私网地址
747
+ * 全都能当同步目标 —— 一旦写进本机配置,git 就会按包方指定的地址执行/送凭据。
748
+ * 白名单口径:只认 http/https/ssh/scp-like 远端;无 host、私网/环回/链路本地、非网络方案、
749
+ * 整串以 '-' 开头一律拒绝。本机路径保留(git 通道的老用法,见 GitTransportOptions.repoUrl 注释)。
750
+ * ---------------------------------------------------------------------------------------------- */
751
+
752
+ test('validateRepoUrl(SEC-02/03/12):白名单 —— http(s)/ssh/scp-like 远端通过,危险方案与私网地址一律拒绝', () => {
753
+ // 通过:真实用法的四种形状(https / http / ssh:// / scp-like)+ 本机路径
754
+ for (const ok of [
755
+ 'https://github.com/u/r.git',
756
+ 'http://git.example.com/u/r.git',
757
+ 'ssh://git@git.example.com:2222/u/r.git',
758
+ 'git@github.com:u/r.git',
759
+ 'git@git.example.com:team/r.git',
760
+ '/home/bob/repos/config.git',
761
+ '../bare',
762
+ 'D:/repos/bare',
763
+ 'https://github.com/u/r-1.git',
764
+ // 私网边界外(172.16/12 之外的 172.x 与 11.x 不是保留段)
765
+ 'https://172.32.0.1/r.git',
766
+ 'https://11.0.0.1/r.git',
767
+ ]) {
768
+ assert.equal(validateRepoUrl(ok), null, '应通过: ' + ok);
769
+ }
770
+
771
+ // 拒绝:方案白名单(SEC-03)+ 无 host + 私网/环回/链路本地(SEC-02)+ 以 '-' 开头(SEC-12)
772
+ for (const bad of [
773
+ 'file:///c:/repos/x.git',
774
+ 'file:///etc/passwd',
775
+ 'ext::sh -c touch% /tmp/pwned',
776
+ 'ftp://git.example.com/r.git',
777
+ 'git://git.example.com/r.git',
778
+ 'https://',
779
+ 'https:///r.git',
780
+ 'https://127.0.0.1:8443/r.git',
781
+ 'http://localhost:3000/r.git',
782
+ 'https://[::1]/r.git',
783
+ 'ssh://git@127.0.0.1/r.git',
784
+ 'git@localhost:r.git',
785
+ 'https://10.0.0.5/r.git',
786
+ 'https://192.168.1.10/r.git',
787
+ 'https://172.16.0.9/r.git',
788
+ 'https://172.31.255.1/r.git',
789
+ 'https://169.254.169.254/latest/meta-data/',
790
+ 'https://[fe80::1]/r.git',
791
+ '-c core.pager=touch /tmp/pwned',
792
+ '--upload-pack=touch /tmp/pwned',
793
+ 'https://user:ghp_token@github.com/u/r.git',
794
+ 'https://github.com/u/r .git',
795
+ ]) {
796
+ const err = validateRepoUrl(bad);
797
+ assert.equal(typeof err, 'string', '必须拒绝: ' + bad);
798
+ assert.ok(err !== null && err.length > 0, '拒绝必须带可展示原因: ' + bad);
799
+ }
800
+ });
801
+
802
+ test('readSyncConfig(SEC-01 读侧):已存 repoUrl 走同一校验;不合法 → 不返回该配置并给明确 issue', async () => {
803
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-repo-'));
804
+ try {
805
+ // 合法:照常读出,无 issue
806
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
807
+ schemaVersion: 3, transport: 'git',
808
+ git: { repoUrl: 'https://github.com/u/r.git' }, webdav: {},
809
+ }, null, 2), 'utf8');
810
+ const issues: SyncConfigIssue[] = [];
811
+ const ok = await readSyncConfig(dir, issues);
812
+ assert.equal(issues.length, 0, '合法 repoUrl 不该产生 issue');
813
+ assert.ok(ok !== null && isGitConfig(ok));
814
+
815
+ // 不合法:包方写进来的 file:// 目标 —— 读侧必须当成「未配置」,且给出明确原因
816
+ for (const badUrl of [
817
+ 'file:///c:/repos/x.git',
818
+ 'ext::sh -c touch% /tmp/pwned',
819
+ 'https://169.254.169.254/latest/meta-data/',
820
+ 'https://user:ghp_x@github.com/u/r.git',
821
+ ]) {
822
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
823
+ schemaVersion: 3, transport: 'git', git: { repoUrl: badUrl }, webdav: {},
824
+ }, null, 2), 'utf8');
825
+ const local: SyncConfigIssue[] = [];
826
+ const cfg = await readSyncConfig(dir, local);
827
+ assert.equal(cfg, null, '不合法的 repoUrl 不得进入后续凭据注入: ' + badUrl);
828
+ assert.equal(local.length, 1, '必须显式记录被拒的通道,不得静默: ' + badUrl);
829
+ assert.equal(local[0]?.code, 'repo-url-invalid');
830
+ assert.equal(local[0]?.channel, 'git');
831
+ assert.ok((local[0]?.message ?? '').length > 0, '必须带可展示原因');
832
+
833
+ // 省略 issue 收集参数时行为不变(老调用方仍拿 null = 未配置)
834
+ assert.equal(await readSyncConfig(dir), null);
835
+ }
836
+
837
+ // v1 旧文件(扁平 repoUrl)走同一校验
838
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({ schemaVersion: 1, repoUrl: 'file:///etc' }), 'utf8');
839
+ const legacyIssues: SyncConfigIssue[] = [];
840
+ assert.equal(await readSyncConfig(dir, legacyIssues), null, 'v1 旧文件的 repoUrl 也要校验');
841
+ assert.equal(legacyIssues.length, 1);
842
+
843
+ // 本机路径仍是 git 通道的合法目标(产品既有能力,不因白名单被砍)
844
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
845
+ schemaVersion: 3, transport: 'git', git: { repoUrl: '/home/bob/repos/config.git' }, webdav: {},
846
+ }, null, 2), 'utf8');
847
+ const localIssues: SyncConfigIssue[] = [];
848
+ const localCfg = await readSyncConfig(dir, localIssues);
849
+ assert.equal(localIssues.length, 0);
850
+ assert.ok(localCfg !== null && isGitConfig(localCfg) && localCfg.git.repoUrl === '/home/bob/repos/config.git');
851
+ } finally {
852
+ await fs.rm(dir, { recursive: true, force: true });
853
+ }
854
+ });
855
+
856
+ /* ------------------------------------------------------------------------------------------------
857
+ * RV1-F6 / F7:私网拒绝的文案口径 + userinfo 覆盖 ssh(复核在 SEC-02/12 里抓到的两处边角)
858
+ *
859
+ * F6:环回/私网复用「无法解析仓库地址」= 给了用户错的方向(他会反复改写法)。真实判据是
860
+ * 「这个地址指向本机/内网,不作为同步目标」,因此单独一条 sync.configPrivateHost。
861
+ * F7:userinfo 检查原来只在 http/https 分支内,`ssh://user:tok@host/r.git` 能过白名单,
862
+ * token 会跟着 repoUrl 进 argv、进 sync-config.json。口径改成「password 位任何 scheme 都拒」。
863
+ * ---------------------------------------------------------------------------------------------- */
864
+
865
+ test('validateRepoUrl(RV1-F6):私网/环回用专属文案,且回显地址已脱敏(不含 userinfo)', () => {
866
+ const cases: Array<[string, string]> = [
867
+ ['https://127.0.0.1:8443/r.git', '127.0.0.1:8443'],
868
+ ['http://localhost:3000/r.git', 'localhost:3000'],
869
+ ['https://192.168.1.10/r.git', '192.168.1.10'],
870
+ ['https://169.254.169.254/latest/meta-data/', '169.254.169.254'],
871
+ ['ssh://git@10.0.0.5/r.git', '10.0.0.5'],
872
+ ];
873
+ for (const [url, host] of cases) {
874
+ const err = validateRepoUrl(url);
875
+ assert.ok(err !== null, '私网地址必须拒绝: ' + url);
876
+ assert.ok(err.includes('本机或内网'), '原因必须是「指向本机/内网」,不能复用解析失败: ' + err);
877
+ assert.ok(!err.includes('无法解析'), '不得再用「无法解析仓库地址」描述私网: ' + err);
878
+ assert.ok(err.includes(host), '要能认出被拒的是哪个地址: ' + err);
879
+ }
880
+
881
+ // 回显脱敏:私网 + userinfo 同时出现时,文案里只能有 host,不能有 user/token
882
+ const withToken = validateRepoUrl('https://user:ghp_secretvalue@192.168.1.10/r.git');
883
+ assert.ok(withToken !== null);
884
+ assert.ok(withToken.includes('192.168.1.10'), '地址主体保留: ' + withToken);
885
+ assert.ok(!withToken.includes('ghp_secretvalue') && !withToken.includes('user:'), '文案不得回显凭据: ' + withToken);
886
+
887
+ // 私网判定先于 userinfo:带 userinfo 的私网地址仍报「内网」,而不是「别带凭据」
888
+ assert.ok(withToken.includes('本机或内网'), '私网判定优先: ' + withToken);
889
+ });
890
+
891
+ test('validateRepoUrl(RV1-F7):带 scheme 的 userinfo —— ssh 的 token 也被拒,ssh 登录名仍放行', () => {
892
+ // ssh 里 password 位 = 可粘贴的 token → 拒
893
+ for (const bad of ['ssh://user:tok@example.com/r.git', 'ssh://git:ghp_abc@example.com:22/u/r.git', 'ssh://:tok@example.com/r.git']) {
894
+ assert.equal(validateRepoUrl(bad), validateRepoUrl('https://user:tok@example.com/r.git'), 'ssh userinfo 与 http 同口径: ' + bad);
895
+ assert.ok((validateRepoUrl(bad) ?? '').includes('用户名/密码'), '必须是 userinfo 文案: ' + bad);
896
+ }
897
+ // ssh 的 username 是登录名(标准写法)→ 放行;否则会杀掉合法用法
898
+ for (const ok of ['ssh://git@git.example.com:2222/u/r.git', 'ssh://deploy@git.example.com/u/r.git', 'git@github.com:u/r.git']) {
899
+ assert.equal(validateRepoUrl(ok), null, 'ssh 登录名不是凭据,必须放行: ' + ok);
900
+ }
901
+ // http(s) 的 username 与口令等同(Basic 认证)→ 仍拒
902
+ for (const bad of ['https://user@github.com/u/r.git', 'http://user@git.example.com/u/r.git']) {
903
+ assert.ok((validateRepoUrl(bad) ?? '').includes('用户名/密码'), 'http(s) 的 username 仍拒: ' + bad);
904
+ }
905
+
906
+ // 解析不了的半截地址也必须脱敏回显(`https://user:tok@` 前缀就是凭据)
907
+ const half = validateRepoUrl('https://user:ghp_halfsecret@');
908
+ assert.ok(half !== null);
909
+ assert.ok(!half.includes('ghp_halfsecret'), '无法解析的地址同样不得回显凭据: ' + half);
910
+ });
@@ -168,12 +168,26 @@ function parseV2WebDavNamespace(ns: unknown): WebDavConfig | null {
168
168
  return webdav;
169
169
  }
170
170
 
171
+ /**
172
+ * 已落盘 repoUrl 的读取侧校验(SEC-01):与写入侧同一份 validateRepoUrl。
173
+ * 不合法 → 记一条明确 issue 并让调用方丢弃该配置;绝不把可疑地址继续交给凭据注入与 git 子进程。
174
+ */
175
+ function acceptPersistedRepoUrl(repoUrl: string, issues?: SyncConfigIssue[]): boolean {
176
+ const err = validateRepoUrl(repoUrl);
177
+ if (err === null) return true;
178
+ issues?.push({ code: 'repo-url-invalid', channel: 'git', message: err });
179
+ return false;
180
+ }
181
+
171
182
  /**
172
183
  * 读取同步通道配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
173
184
  * 兼容旧文件:缺 schemaVersion 字段视为 v1(git 通道)。
174
185
  * 恒返回 schemaVersion=2 的规范形态(v1 读取时归一为 git)。
186
+ *
187
+ * `issues` 可选:传数组时,被丢弃的 git repoUrl(白名单校验不通过)以结构化成因追加进去,
188
+ * 供 UI/日志如实呈现(不静默吞掉)。
175
189
  */
176
- export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
190
+ export async function readSyncConfig(dir: string, issues?: SyncConfigIssue[]): Promise<SyncConfig | null> {
177
191
  const file = path.join(dir, SYNC_CONFIG_FILE);
178
192
  let raw: string;
179
193
  try {
@@ -200,6 +214,7 @@ export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
200
214
  if (ver === 1) {
201
215
  const git = parseV1Git(obj);
202
216
  if (git === null) return null;
217
+ if (!acceptPersistedRepoUrl(git.repoUrl, issues)) return null;
203
218
  return { schemaVersion: 2, transport: 'git', git };
204
219
  }
205
220
 
@@ -210,6 +225,7 @@ export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
210
225
  if (transport === 'git') {
211
226
  const git = parseV2GitNamespace(obj['git']);
212
227
  if (git === null) return null;
228
+ if (!acceptPersistedRepoUrl(git.repoUrl, issues)) return null;
213
229
  return { schemaVersion: 2, transport: 'git', git };
214
230
  }
215
231
  if (transport === 'webdav') {
@@ -264,9 +280,14 @@ function readBothNamespaces(file: string): Promise<{ git?: GitConfig; webdav?: W
264
280
  * 读取完整的双命名空间配置(供 status 路由回填另一通道的 repoUrl/url)。
265
281
  * 文件不存在/损坏/无任何通道配置 → null(视为未配置)。
266
282
  */
267
- export async function readFullSyncConfig(dir: string): Promise<FullSyncConfig | null> {
283
+ export async function readFullSyncConfig(dir: string, issues?: SyncConfigIssue[]): Promise<FullSyncConfig | null> {
268
284
  const file = path.join(dir, SYNC_CONFIG_FILE)
269
285
  const both = await readBothNamespaces(file)
286
+ // SEC-01/03 读侧:已落盘的 git repoUrl 过同一份白名单校验;不合法就当作没配 git 通道
287
+ // (自动同步/凭据注入都从这里取通道配置,绝不能让可疑地址继续往下走)
288
+ if (both.git !== undefined && !acceptPersistedRepoUrl(both.git.repoUrl, issues)) {
289
+ delete both.git
290
+ }
270
291
  // 云端点通道的配置不在 sync-config.json(见 sync-cloud-config.json);这里一并对齐成完整视图
271
292
  const s3 = await readS3ChannelConfig(dir)
272
293
  const gist = await readGistChannelConfig(dir)
@@ -296,8 +317,12 @@ export async function readFullSyncConfig(dir: string): Promise<FullSyncConfig |
296
317
  * 读取指定通道的同步通道配置(供自动同步调度器按通道运行)。
297
318
  * 从完整双命名空间配置取对应通道构造可辨识联合 SyncConfig;该通道未配置 → null。
298
319
  */
299
- export async function readSyncConfigFor(dir: string, channel: SyncTransportType): Promise<SyncConfig | null> {
300
- const full = await readFullSyncConfig(dir);
320
+ export async function readSyncConfigFor(
321
+ dir: string,
322
+ channel: SyncTransportType,
323
+ issues?: SyncConfigIssue[],
324
+ ): Promise<SyncConfig | null> {
325
+ const full = await readFullSyncConfig(dir, issues);
301
326
  if (full === null) return null;
302
327
  switch (channel) {
303
328
  case 'git':
@@ -430,10 +455,135 @@ export async function clearSyncChannel(dir: string, channel: SyncTransportType):
430
455
  return { removed: true, hasRemaining: true, transport };
431
456
  }
432
457
 
458
+ /** repoUrl 形态:网络地址(http/https/ssh,含 scp 式)或本机路径 */
459
+ export type RepoUrlKind = 'http' | 'https' | 'ssh' | 'local';
460
+
461
+ /**
462
+ * 已落盘 repoUrl 校验失败的**结构化**结果(SEC-01 读侧)。
463
+ * 读取侧不能只是返回 null:调用方要能如实告诉用户「你的配置被丢弃了,原因是地址不合法」。
464
+ */
465
+ export interface SyncConfigIssue {
466
+ code: 'repo-url-invalid';
467
+ channel: SyncTransportType;
468
+ message: string;
469
+ }
470
+
471
+ /**
472
+ * repoUrl 形态分类;null = 不认识的形态。
473
+ *
474
+ * 为什么要分类而不是只做正则:git 对同一个「地址」支持 http(s)、ssh、scp 式与本机路径,
475
+ * 而这三类的安全含义完全不同(网络地址要防 SSRF,本机路径要防 argv 注入)。先判形态,
476
+ * 再按形态分别收紧,比一堆互相打架的正则清楚。
477
+ */
478
+ export function classifyRepoUrl(repoUrl: string): { kind: RepoUrlKind; host: string } | null {
479
+ const url = repoUrl.trim();
480
+ if (url === '' || /\s/.test(url)) return null;
481
+ // SEC-12:以 '-' 开头的「地址」会被 git 当成选项(--upload-pack=… / -c core.pager=…)
482
+ if (url.startsWith('-')) return null;
483
+ // 'ext::sh -c …' 一类命令协议(SCM 协议语法里 scheme 与地址之间是 '::')
484
+ if (url.includes('::')) return null;
485
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url);
486
+ if (scheme !== null) {
487
+ const name = scheme[1]!.toLowerCase();
488
+ if (name !== 'http' && name !== 'https' && name !== 'ssh') return null;
489
+ // 'https:///r.git' 这类空 authority 会被 URL 解析器「跳过斜杠」后当成主机 r.git —— 必须先拦
490
+ const rest = url.slice(scheme[0].length);
491
+ if (rest === '' || rest.startsWith('/') || rest.startsWith('\\')) return null;
492
+ let parsed: URL;
493
+ try {
494
+ parsed = new URL(url);
495
+ } catch {
496
+ return null;
497
+ }
498
+ if (parsed.hostname === '') return null;
499
+ return { kind: name, host: parsed.hostname };
500
+ }
501
+ // scp 式(git@github.com:user/repo.git):只有 user@host:path 才算网络地址
502
+ const scp = /^[^\s/@:]+@([^\s/:]+):(.+)$/.exec(url);
503
+ if (scp !== null) return { kind: 'ssh', host: scp[1]! };
504
+ // 其余按本机路径处理(相对 ../bare、绝对 /home/u/r.git、盘符 D:/repos/bare)
505
+ return { kind: 'local', host: '' };
506
+ }
507
+
508
+ /** 四段点分且每段 0-255 → IPv4 字面量;否则 null(域名里的数字段不算 IP) */
509
+ function parseIpv4(host: string): [number, number, number, number] | null {
510
+ const parts = host.split('.');
511
+ if (parts.length !== 4) return null;
512
+ const nums: number[] = [];
513
+ for (const part of parts) {
514
+ if (!/^\d{1,3}$/.test(part)) return null;
515
+ const n = Number(part);
516
+ if (n > 255) return null;
517
+ nums.push(n);
518
+ }
519
+ return [nums[0]!, nums[1]!, nums[2]!, nums[3]!];
520
+ }
521
+
522
+ /**
523
+ * 环回 / 私网 / 链路本地 / 未指定地址(含云元数据 169.254.169.254)判定。
524
+ * SEC-03:这类地址让「客户端」去访问内网或本机端口,等于给 SSRF 开一扇门。
525
+ *
526
+ * **已知行为变更(有意,非副作用)**:SEC-02/03 之前 `https://192.168.1.10/…`、`http://127.0.0.1:3000/…`、
527
+ * `http://nas.local/…` 这类地址可以被接受。收紧后一律拒绝 —— 代价是「拿家里 NAS / 局域网 git 服务器
528
+ * 当同步目标」这条路走不通,收益是「包里的 repoUrl 无法把家目录配置推给内网监听者或云元数据端点」。
529
+ * 取舍判据:本插件的同步目标是「用户自己的远端仓库」,公网 http(s)/ssh 已覆盖该用途;本机目录仓库
530
+ * 仍走 `local` 分支(不由本函数判定)。真要支持内网目标,应做成显式开关 + 用户逐目标确认,
531
+ * 不能靠「地址字符串看起来像内网就放行」——那正是攻击者可控的判据。
532
+ */
533
+ export function isPrivateRepoHost(host: string): boolean {
534
+ const h = host.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '');
535
+ if (h === '') return true;
536
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
537
+ const v4 = parseIpv4(h);
538
+ if (v4 !== null) {
539
+ const [a, b] = v4;
540
+ return a === 0 || a === 127 || a === 10
541
+ || (a === 172 && b >= 16 && b <= 31)
542
+ || (a === 192 && b === 168)
543
+ || (a === 169 && b === 254);
544
+ }
545
+ if (!h.includes(':')) return false;
546
+ if (h === '::1' || h === '::') return true;
547
+ if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 唯一本地
548
+ if (/^fe[89ab][0-9a-f]?:/.test(h)) return true; // fe80::/10 链路本地
549
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
550
+ if (mapped !== null) return isPrivateRepoHost(mapped[1]!);
551
+ return false;
552
+ }
553
+
554
+ /**
555
+ * 文案里回显地址用:剥掉 userinfo(token 可能就藏在这一段),host/path 原样保留。
556
+ *
557
+ * 为什么不整串换成 `[REPO_URL]` 占位符:这里拒绝的是**用户自己刚填的**地址,他需要认出
558
+ * 「被拒的是哪一个」才能改;剥掉 `user:pass@` 之后剩下的部分正是修复所需的定位信息。
559
+ * 为什么解析失败也要剥:`https://user:tok@` 这类半截地址 `new URL` 会抛,但前缀照样是凭据。
560
+ */
561
+ export function sanitizeRepoUrlForMessage(url: string): string {
562
+ const raw = url.trim();
563
+ try {
564
+ const parsed = new URL(raw);
565
+ if (parsed.username === '' && parsed.password === '') return raw;
566
+ parsed.username = '';
567
+ parsed.password = '';
568
+ return parsed.toString();
569
+ } catch {
570
+ // 只有「残缺的带 scheme 地址」(`https://user:tok@`,`new URL` 会抛但前缀就是凭据)需要剥。
571
+ // 无 scheme 的 scp 式(`git@host:path`)里 `git` 是 ssh 登录名、不是凭据 → 原样返回,
572
+ // 免得用户看到一条认不出是哪个远端的报错。
573
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) return raw;
574
+ return raw.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/@\s]*@/, (_m: string, scheme?: string) => scheme ?? '');
575
+ }
576
+ }
577
+
433
578
  /**
434
579
  * 仓库地址合法性校验(返回错误消息;null = 合法)。
435
- * 安全约束:token 永不拼入 repoUrl —— http(s) 地址带 userinfo(username[:password]@)直接拒绝,
436
- * 引导用户把 token 放凭据字段(DSH credentials),避免 token 经 URL 泄漏进 git 历史/日志。
580
+ *
581
+ * 白名单口径(SEC-02/03/12,写入侧与读取侧共用同一份实现):
582
+ * - 只认 http(s)/ssh/scp 式网络地址与本机路径;file://、ext::、ftp://、git:// 一律拒绝;
583
+ * - 整串以 '-' 开头拒绝(git argv 位置参数以 '-' 开头会被当选项,可执行任意命令);
584
+ * - 网络地址拒绝环回/私网/链路本地主机(SSRF)—— 文案单独一条(sync.configPrivateHost),
585
+ * 不能复用「无法解析」:用户看到「解析不了」只会反复改写法,实际原因是地址指向内网;
586
+ * - 带 scheme 的地址一律拒绝 userinfo(username[:password]@)—— token 只许放凭据字段。
437
587
  */
438
588
  export function validateRepoUrl(repoUrl: string, msg: MsgFunc = zhMsg): string | null {
439
589
  if (typeof repoUrl !== 'string' || repoUrl.trim() === '') {
@@ -443,14 +593,34 @@ export function validateRepoUrl(repoUrl: string, msg: MsgFunc = zhMsg): string |
443
593
  if (/\s/.test(url)) {
444
594
  return msg('sync.configWhitespace');
445
595
  }
446
- if (/^https?:\/\//i.test(url)) {
447
- let parsed: URL;
448
- try {
449
- parsed = new URL(url);
450
- } catch {
451
- return msg('sync.configUnparseable', { url });
452
- }
453
- if (parsed.username !== '' || parsed.password !== '') {
596
+ // 回显一律脱敏:错误文案会进日志、进接口响应、进 issue 截图
597
+ const echo = sanitizeRepoUrlForMessage(url);
598
+ const shape = classifyRepoUrl(url);
599
+ if (shape === null) {
600
+ return msg('sync.configUnparseable', { url: echo });
601
+ }
602
+ if (shape.kind === 'local') return null;
603
+ if (isPrivateRepoHost(shape.host)) {
604
+ return msg('sync.configPrivateHost', { url: echo });
605
+ }
606
+ // userinfo 检查与 scheme 无关(RV1-F7):原实现只在 http/https 分支内查,于是
607
+ // `ssh://user:tok@host/r.git` 能过白名单,token 照样进 argv 与 sync-config.json。
608
+ // 口径(按「这一段是不是凭据」区分,而不是按 scheme 区分):
609
+ // - password 位:任何带 scheme 的地址都拒 —— 那是可粘贴的 secret;
610
+ // - http/https 的 username:Basic 认证的用户名与口令等同,一并拒(保持既有行为);
611
+ // - ssh 的 username:就是 ssh 登录名(`ssh://git@host`、`ssh://deploy@host` 是标准写法),
612
+ // ssh 认证只认密钥/agent,用户名不具凭据效力,因此放行 —— 拒掉会杀掉合法用法。
613
+ // scp 式(git@host:path)无 scheme、`new URL` 解析不了,本就不进这条判定。
614
+ let parsed: URL | null = null;
615
+ try {
616
+ parsed = new URL(url);
617
+ } catch {
618
+ parsed = null;
619
+ }
620
+ if (parsed !== null) {
621
+ const hasPassword = parsed.password !== '';
622
+ const hasBasicUser = parsed.username !== '' && (shape.kind === 'http' || shape.kind === 'https');
623
+ if (hasPassword || hasBasicUser) {
454
624
  return msg('sync.configUserinfo');
455
625
  }
456
626
  }