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
@@ -11,7 +11,7 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { execFile } from 'node:child_process';
13
13
 
14
- import { clearInspectFactsCache, GitTransport, GitTransportError } from './git-transport.ts';
14
+ import { clearInspectFactsCache, defaultGitExec, GitTransport, GitTransportError } from './git-transport.ts';
15
15
  import type { GitExecFn, GitExecResult, GitTransportOptions } from './git-transport.ts';
16
16
  import { encryptSectionsPayload } from '../snapshot-crypto.ts';
17
17
  import { computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections, SyncTransportError } from '../transport.ts';
@@ -119,6 +119,29 @@ function joinedArgs(calls: CallRecord[], needle: string): string[] {
119
119
  return calls.filter((c) => c.args.join(' ').includes(needle)).map((c) => c.args.join(' '));
120
120
  }
121
121
 
122
+ /** 去掉 argv 前置的协议护栏(-c protocol.*.allow=never),用于断言「业务参数」本身 */
123
+ function stripProtocolGuards(joined: string): string {
124
+ return joined.replace(/^(?:-c protocol\.[a-z]+\.allow=never )+/, '');
125
+ }
126
+
127
+ /** 单条调用是否带某个 -c 配置(必须成对出现) */
128
+ function hasGitConfig(args: string[], kv: string): boolean {
129
+ const i = args.indexOf(kv);
130
+ return i > 0 && args[i - 1] === '-c';
131
+ }
132
+
133
+ /**
134
+ * 拉取命令判定(issue #122):fetch 与 origin 之间现在会插 `--no-tags`/`--filter=blob:none`,
135
+ * 所以不能再按 'fetch origin' 子串匹配。
136
+ */
137
+ function isFetchArgs(args: string[]): boolean {
138
+ return args.includes('fetch') && args.includes('origin');
139
+ }
140
+
141
+ function fetchCalls(calls: CallRecord[]): string[] {
142
+ return calls.filter((c) => isFetchArgs(c.args)).map((c) => c.args.join(' '));
143
+ }
144
+
122
145
  /** 构造加密快照(sections 为 EncryptedSections 密文载荷;含文件分区以覆盖字节往返)。 */
123
146
  async function encryptedSnapshot(overrides: Partial<SyncSnapshot> = {}): Promise<SyncSnapshot> {
124
147
  const base = sampleSnapshot();
@@ -896,7 +919,7 @@ function pullScriptedExec(calls: CallRecord[], pullResults: GitExecResult[]): Gi
896
919
  const joined = args.join(' ');
897
920
  if (joined.includes('rev-parse') && joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
898
921
  if (joined.includes('rev-parse')) return { stdout: 'sha', stderr: '', code: 0 };
899
- if (joined.includes('fetch origin')) {
922
+ if (isFetchArgs(args)) {
900
923
  const r = pullResults[Math.min(i, pullResults.length - 1)]!;
901
924
  i += 1;
902
925
  return r;
@@ -916,7 +939,7 @@ test('t24:幂等读(list)遇瞬时网络故障 → 指数退避重试后
916
939
  workDir: dir, exec, retry: { attempts: 3, baseDelayMs: 1, sleep: async () => {} },
917
940
  }));
918
941
  assert.deepEqual(await transport.list(), [], '重试后应成功(空远端)');
919
- assert.equal(joinedArgs(calls, 'fetch origin').length, 2, '第一次瞬时失败 → 重试一次即成功');
942
+ assert.equal(fetchCalls(calls).length, 2, '第一次瞬时失败 → 重试一次即成功');
920
943
  });
921
944
 
922
945
  test('t24:重试耗尽后上抛带分类的错误(network / retryable=true),尝试次数 = attempts', async (t) => {
@@ -933,7 +956,7 @@ test('t24:重试耗尽后上抛带分类的错误(network / retryable=true
933
956
  assert.equal(err.retryable, true);
934
957
  return true;
935
958
  });
936
- assert.equal(joinedArgs(calls, 'fetch origin').length, 3, 'attempts=3 → 恰好 3 次尝试');
959
+ assert.equal(fetchCalls(calls).length, 3, 'attempts=3 → 恰好 3 次尝试');
937
960
  });
938
961
 
939
962
  test('t24:不可重试错误(仓库不存在 / 鉴权失败)→ 只尝试一次且分类正确', async (t) => {
@@ -954,7 +977,7 @@ test('t24:不可重试错误(仓库不存在 / 鉴权失败)→ 只尝试
954
977
  assert.equal(err.retryable, false, '不可重试: ' + stderr);
955
978
  return true;
956
979
  });
957
- assert.equal(joinedArgs(calls, 'fetch origin').length, 1, '不可重试错误不得重试: ' + stderr);
980
+ assert.equal(fetchCalls(calls).length, 1, '不可重试错误不得重试: ' + stderr);
958
981
  }
959
982
  });
960
983
 
@@ -965,10 +988,13 @@ test('远端同步命令序列:显式 refspec fetch + merge --ff-only,绝不
965
988
  workDir: dir, exec: pullScriptedExec(calls, [{ stdout: '', stderr: '', code: 0 }]),
966
989
  }));
967
990
  await transport.list();
968
- const joined = calls.map((c) => c.args.join(' '));
991
+ // SEC-12:每条 argv 前都挂协议护栏,断言业务参数前先剥掉
992
+ const joined = calls.map((c) => stripProtocolGuards(c.args.join(' ')));
969
993
  assert.ok(!joined.some((j) => /^pull(\s|$)/.test(j)), '不得再执行 git pull: ' + joined.join(' | '));
970
994
  // 网络命令前面会挂 credential helper 的 -c 参数,所以按「末尾 3 个参数」判命令本身
971
- const fetched = calls.some((c) => c.args.slice(-3).join(' ') === 'fetch origin +refs/heads/main:refs/remotes/origin/main');
995
+ // issue #122:fetch 与 origin 之间还会插 `--no-tags`(partial clone 时另有 `--filter=blob:none`),
996
+ // 所以按「含 fetch / 含 origin / 末尾是显式 refspec」判定,而不是钉死参数个数
997
+ const fetched = calls.some((c) => isFetchArgs(c.args) && c.args.at(-1) === '+refs/heads/main:refs/remotes/origin/main');
972
998
  assert.ok(fetched, '必须显式 refspec fetch: ' + joined.join(' | '));
973
999
  assert.ok(joined.includes('merge --ff-only origin/main'), '必须显式远端分支快进合并: ' + joined.join(' | '));
974
1000
  });
@@ -981,7 +1007,7 @@ test('远端还没有同名分支(空仓库 / 分支被改名)→ 与「无
981
1007
  const joined = args.join(' ');
982
1008
  if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
983
1009
  if (joined.includes('rev-parse')) return { stdout: 'sha', stderr: '', code: 0 };
984
- if (joined.includes('fetch origin')) {
1010
+ if (isFetchArgs(args)) {
985
1011
  return { stdout: '', stderr: "fatal: couldn't find remote ref refs/heads/main", code: 128 };
986
1012
  }
987
1013
  return { stdout: '', stderr: '', code: 0 };
@@ -991,6 +1017,168 @@ test('远端还没有同名分支(空仓库 / 分支被改名)→ 与「无
991
1017
  assert.equal(calls.filter((c) => c.args.includes('merge')).length, 0, '没 fetch 到东西就不该 merge');
992
1018
  });
993
1019
 
1020
+ /* ---------------- issue #122:远端仓库远大于同步内容时不许卡死 ---------------- */
1021
+
1022
+ const SHA_A = 'a'.repeat(40);
1023
+ const SHA_B = 'b'.repeat(40);
1024
+
1025
+ test('issue #122:远端 sha 与本地跟踪引用一致且已合并 → 整轮零 fetch、零 merge(只花一次 ls-remote)', async (t) => {
1026
+ const dir = await makeGitWorkDir(t);
1027
+ const calls: CallRecord[] = [];
1028
+ const exec: GitExecFn = async (_cmd, args) => {
1029
+ calls.push({ cmd: 'git', args });
1030
+ const joined = args.join(' ');
1031
+ if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
1032
+ if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
1033
+ if (joined.includes('rev-parse')) return { stdout: SHA_A, stderr: '', code: 0 };
1034
+ return { stdout: '', stderr: '', code: 0 };
1035
+ };
1036
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
1037
+ assert.deepEqual(await transport.list(), []);
1038
+ const trace = calls.map((c) => c.args.join(' ')).join(' | ');
1039
+ assert.equal(fetchCalls(calls).length, 0, '稳态不得发 fetch: ' + trace);
1040
+ assert.equal(calls.filter((c) => c.args.includes('merge')).length, 0, '稳态不得 merge: ' + trace);
1041
+ assert.equal(joinedArgs(calls, 'ls-remote').length, 1, '稳态只允许一次 ls-remote: ' + trace);
1042
+ });
1043
+
1044
+ test('issue #122:refs 已是最新但 HEAD 落后(上次 merge 没跑完)→ 跳过 fetch、仍本地 merge', async (t) => {
1045
+ const dir = await makeGitWorkDir(t);
1046
+ const calls: CallRecord[] = [];
1047
+ const exec: GitExecFn = async (_cmd, args) => {
1048
+ calls.push({ cmd: 'git', args });
1049
+ const joined = args.join(' ');
1050
+ if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
1051
+ if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
1052
+ // 远端跟踪引用已指向远端最新,但 HEAD 还停在旧提交
1053
+ if (joined.includes('refs/remotes/origin/main')) return { stdout: SHA_A, stderr: '', code: 0 };
1054
+ if (joined.includes('rev-parse')) return { stdout: SHA_B, stderr: '', code: 0 };
1055
+ return { stdout: '', stderr: '', code: 0 };
1056
+ };
1057
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
1058
+ assert.deepEqual(await transport.list(), []);
1059
+ assert.equal(fetchCalls(calls).length, 0, '没有新对象可拉 → 不 fetch');
1060
+ assert.equal(joinedArgs(calls, 'merge --ff-only origin/main').length, 1, '本地没合并完 → 必须继续 merge');
1061
+ });
1062
+
1063
+ test('issue #122:远端有新提交 → 正常 fetch + merge,fetch 带 --no-tags(同步仓库不需要 tag)', async (t) => {
1064
+ const dir = await makeGitWorkDir(t);
1065
+ const calls: CallRecord[] = [];
1066
+ const exec: GitExecFn = async (_cmd, args) => {
1067
+ calls.push({ cmd: 'git', args });
1068
+ const joined = args.join(' ');
1069
+ if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
1070
+ if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
1071
+ if (joined.includes('refs/remotes/origin/main')) return { stdout: SHA_B, stderr: '', code: 0 };
1072
+ if (joined.includes('rev-parse')) return { stdout: SHA_B, stderr: '', code: 0 };
1073
+ return { stdout: '', stderr: '', code: 0 };
1074
+ };
1075
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
1076
+ assert.deepEqual(await transport.list(), []);
1077
+ const fetches = fetchCalls(calls);
1078
+ assert.equal(fetches.length, 1, '远端有新提交 → 必须 fetch: ' + calls.map((c) => c.args.join(' ')).join(' | '));
1079
+ assert.ok(fetches[0]!.includes('--no-tags'), 'fetch 必须带 --no-tags: ' + fetches[0]!);
1080
+ assert.ok(!fetches[0]!.includes('--filter='), '非 partial clone 不得加 blob 过滤: ' + fetches[0]!);
1081
+ assert.equal(joinedArgs(calls, 'merge --ff-only origin/main').length, 1, 'fetch 之后必须 ff 合并');
1082
+ });
1083
+
1084
+ test('issue #122:partial clone 的 fetch 保持 --filter=blob:none(不因过滤丢失后续 checkout 需要的 blob)', async (t) => {
1085
+ const dir = await makeGitWorkDir(t);
1086
+ const calls: CallRecord[] = [];
1087
+ const exec: GitExecFn = async (_cmd, args) => {
1088
+ calls.push({ cmd: 'git', args });
1089
+ const joined = args.join(' ');
1090
+ if (joined.includes('remote.origin.promisor')) return { stdout: 'true\n', stderr: '', code: 0 };
1091
+ if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
1092
+ if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
1093
+ if (joined.includes('refs/remotes/origin/main')) return { stdout: SHA_B, stderr: '', code: 0 };
1094
+ if (joined.includes('rev-parse')) return { stdout: SHA_B, stderr: '', code: 0 };
1095
+ return { stdout: '', stderr: '', code: 0 };
1096
+ };
1097
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
1098
+ assert.deepEqual(await transport.list(), []);
1099
+ const fetches = fetchCalls(calls);
1100
+ assert.equal(fetches.length, 1);
1101
+ assert.ok(fetches[0]!.includes('--filter=blob:none'), 'partial clone 必须保持 blob 过滤: ' + fetches[0]!);
1102
+ });
1103
+
1104
+ test('issue #122:网络子命令一律带低速上限,本地子命令不带', async (t) => {
1105
+ const dir = await makeGitWorkDir(t);
1106
+ const calls: CallRecord[] = [];
1107
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec: pullScriptedExec(calls, [{ stdout: '', stderr: '', code: 0 }]) }));
1108
+ await transport.list();
1109
+ const net = calls.filter((c) => ['clone', 'fetch', 'push', 'ls-remote'].some((sub) => c.args.includes(sub)));
1110
+ assert.ok(net.length > 0, '至少应有 ls-remote/fetch: ' + calls.map((c) => c.args.join(' ')).join(' | '));
1111
+ for (const c of net) {
1112
+ const j = c.args.join(' ');
1113
+ assert.ok(j.includes('http.lowSpeedLimit=1000') && j.includes('http.lowSpeedTime=30'), '网络命令必须带低速上限: ' + j);
1114
+ }
1115
+ const local = calls.filter((c) => c.args.includes('rev-parse'));
1116
+ assert.ok(local.length > 0);
1117
+ for (const c of local) assert.ok(!c.args.join(' ').includes('lowSpeedLimit'), '本地命令不该带 http 配置: ' + c.args.join(' '));
1118
+ });
1119
+
1120
+ test('issue #122:首次 clone 走 partial clone + sparse-checkout,只 materialize 三个同步目录', async (t) => {
1121
+ const dir = await makeTempDir(t); // 空目录 → 走 clone
1122
+ const calls: CallRecord[] = [];
1123
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
1124
+ await transport.list();
1125
+ const clone = calls.find((c) => c.args.includes('clone'));
1126
+ assert.ok(clone !== undefined, '应触发 clone: ' + calls.map((c) => c.args.join(' ')).join(' | '));
1127
+ assert.ok(clone.args.includes('--filter=blob:none'), 'clone 必须 partial(只取 commit+tree): ' + clone.args.join(' '));
1128
+ assert.ok(clone.args.includes('--no-checkout'), 'clone 先不落地文件: ' + clone.args.join(' '));
1129
+ assert.equal(joinedArgs(calls, 'clone').length, 1, '不允许多 clone 一次');
1130
+ const sparse = calls.find((c) => c.args.includes('sparse-checkout'));
1131
+ assert.ok(sparse !== undefined, '必须设置 sparse-checkout');
1132
+ assert.equal(
1133
+ stripProtocolGuards(sparse.args.join(' ')),
1134
+ 'sparse-checkout set --cone snapshots snapshots-encrypted blobs',
1135
+ 'sparse 面必须与同步目录同源: ' + sparse.args.join(' '),
1136
+ );
1137
+ assert.equal(calls.filter((c) => c.args.includes('checkout')).length, 1, 'sparse 之后必须 checkout 一次');
1138
+ });
1139
+
1140
+ test('issue #122:老 git 不认 --filter → 回落全量 clone(绝不因优化失败)', async (t) => {
1141
+ const dir = await makeTempDir(t);
1142
+ const calls: CallRecord[] = [];
1143
+ const exec: GitExecFn = async (_cmd, args) => {
1144
+ calls.push({ cmd: 'git', args });
1145
+ if (args.includes('--filter=blob:none')) {
1146
+ return { stdout: '', stderr: "error: unknown option `filter=blob:none'", code: 129 };
1147
+ }
1148
+ return { stdout: '', stderr: '', code: 0 };
1149
+ };
1150
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
1151
+ await transport.list();
1152
+ const clones = calls.filter((c) => c.args.includes('clone'));
1153
+ assert.equal(clones.length, 2, '必须先试 partial、再回落全量: ' + clones.map((c) => c.args.join(' ')).join(' | '));
1154
+ assert.ok(!clones[1]!.args.includes('--filter=blob:none'), '回落那次不得带 --filter');
1155
+ assert.equal(calls.filter((c) => c.args.includes('sparse-checkout')).length, 0, '回落路径不做 sparse');
1156
+ });
1157
+
1158
+ test('issue #122:超时 kill 整棵进程树,且有界收尾(孙进程不再继续跑)', async (t) => {
1159
+ const dir = await makeTempDir(t);
1160
+ const marker = path.join(dir, 'grandchild-alive.txt');
1161
+ // 父进程拉起孙进程后常驻;孙进程 1.5 s 后写标记文件 —— 只杀父进程时它会照写
1162
+ await fs.writeFile(path.join(dir, 'grandchild.cjs'), [
1163
+ "setTimeout(() => { require('node:fs').writeFileSync(process.argv[2], 'alive'); }, 1500);",
1164
+ '',
1165
+ ].join('\n'));
1166
+ await fs.writeFile(path.join(dir, 'parent.cjs'), [
1167
+ "const { spawn } = require('node:child_process');",
1168
+ "spawn(process.execPath, [require('node:path').join(__dirname, 'grandchild.cjs'), process.argv[2]], { stdio: 'ignore' });",
1169
+ 'setInterval(() => {}, 1000);',
1170
+ '',
1171
+ ].join('\n'));
1172
+ const started = Date.now();
1173
+ const res = await defaultGitExec(process.execPath, [path.join(dir, 'parent.cjs'), marker], { timeoutMs: 200 });
1174
+ const elapsed = Date.now() - started;
1175
+ assert.notEqual(res.code, 0, '超时必须是非零退出');
1176
+ assert.match(res.stderr, /timed out after 200ms/, '超时文案必须让分类器判 timeout(瞬时、可重试)');
1177
+ assert.ok(elapsed < 4000, `超时收尾必须有界(实际 ${String(elapsed)}ms)`);
1178
+ await new Promise((resolve) => setTimeout(resolve, 2500));
1179
+ await assert.rejects(fs.stat(marker), '孙进程必须被一起杀掉(否则它会继续写文件/继续下 pack)');
1180
+ });
1181
+
994
1182
  test('并发驱动同一工作副本(两个 transport 实例)→ git 调用被工作副本锁串行化', async (t) => {
995
1183
  const dir = await makeGitWorkDir(t);
996
1184
  let active = 0;
@@ -1149,10 +1337,10 @@ test('issue #79:已有工作副本且 origin 与 repoUrl 不一致 → set-url
1149
1337
  exec: probeExec(calls, 'ssh://git@old.example:2222/owner/repo.git'),
1150
1338
  }));
1151
1339
  await transport.list();
1152
- assert.deepEqual(joinedArgs(calls, 'remote set-url'), [
1153
- `remote set-url origin ${target}`,
1154
- `remote set-url --push origin ${target}`,
1155
- ], 'fetch 与 push 地址都必须跟随(否则推送仍走旧地址)');
1340
+ assert.deepEqual(joinedArgs(calls, 'remote set-url').map(stripProtocolGuards), [
1341
+ `remote set-url origin -- ${target}`,
1342
+ `remote set-url --push origin -- ${target}`,
1343
+ ], 'fetch 与 push 地址都必须跟随(否则推送仍走旧地址);repoUrl 恒由 -- 结束选项解析');
1156
1344
  });
1157
1345
 
1158
1346
  test('issue #79:origin 已一致 → 一条写命令都不发(幂等,只有只读探针)', async (t) => {
@@ -1171,7 +1359,7 @@ test('issue #79:无 origin(读不出来)→ remote add origin,否则 pus
1171
1359
  const target = 'ssh://git@fresh.example:2222/owner/repo.git';
1172
1360
  const transport = new GitTransport(makeOptions({ repoUrl: target, workDir: dir, exec: probeExec(calls, null) }));
1173
1361
  await transport.list();
1174
- assert.deepEqual(joinedArgs(calls, 'remote add origin'), [`remote add origin ${target}`]);
1362
+ assert.deepEqual(joinedArgs(calls, 'remote add origin').map(stripProtocolGuards), [`remote add origin -- ${target}`]);
1175
1363
  });
1176
1364
 
1177
1365
  test('issue #79:origin 读得出但为空串 → 视为读不到,不改写(不拿不确定的读取覆盖配置)', async (t) => {
@@ -1194,7 +1382,7 @@ test('issue #79:Host key verification failed → 可操作指纹指引(含 h
1194
1382
  calls.push({ cmd: 'git', args });
1195
1383
  const joined = args.join(' ');
1196
1384
  if (joined.includes('remote get-url')) return { stdout: repoUrl, stderr: '', code: 0 };
1197
- if (joined.includes('fetch origin')) {
1385
+ if (isFetchArgs(args)) {
1198
1386
  return { stdout: '', stderr: 'Host key verification failed.\nfatal: Could not read from remote repository.', code: 128 };
1199
1387
  }
1200
1388
  return { stdout: 'sha', stderr: '', code: 0 };
@@ -1540,3 +1728,152 @@ test('只读自检有界:锁被活进程持有时报「工作副本忙」,
1540
1728
  });
1541
1729
 
1542
1730
 
1731
+
1732
+ /* ------------------------------------------------------------------------------------------------
1733
+ * SEC-12 / SEC-03:repoUrl 不得被 git 当成命令行选项,也不得借协议开关读到本机任意仓库
1734
+ *
1735
+ * 事实:repoUrl 原样进 argv。于是「--upload-pack=touch /tmp/pwn」「-c core.pager=…」这类取值会被
1736
+ * git 当成**选项**执行;「file:///…」则让 git 用本地文件协议去读任意本机仓库(sync 通道本该只碰
1737
+ * 同步内容)。三道防线:构造期白名单(方案/主机/首字符)→ argv 恒带协议护栏 → repoUrl 前恒有 --。
1738
+ * ---------------------------------------------------------------------------------------------- */
1739
+ test('SEC-12:以 - 开头 / 非网络方案 / 私网环回地址的 repoUrl → 构造期直接拒绝(一条 git 都不 spawn)', async (t) => {
1740
+ const dir = await makeTempDir(t);
1741
+ for (const repoUrl of [
1742
+ '--upload-pack=touch /tmp/pwned',
1743
+ '-c core.pager=touch /tmp/pwned',
1744
+ 'file:///c:/repos/x.git',
1745
+ 'ext::sh -c touch% /tmp/pwned',
1746
+ 'ftp://git.example.com/r.git',
1747
+ 'git://git.example.com/r.git',
1748
+ 'https://127.0.0.1:8443/r.git',
1749
+ 'https://[::1]/r.git',
1750
+ 'https://169.254.169.254/latest/meta-data/',
1751
+ 'https://10.1.2.3/r.git',
1752
+ 'https://172.20.0.1/r.git',
1753
+ 'https://192.168.0.9/r.git',
1754
+ ]) {
1755
+ const calls: CallRecord[] = [];
1756
+ assert.throws(
1757
+ () => new GitTransport(makeOptions({ repoUrl, workDir: dir, exec: mockExec(calls) })),
1758
+ (err: unknown) => {
1759
+ assert.ok(err instanceof GitTransportError, '必须是 GitTransportError: ' + repoUrl);
1760
+ assert.match(err.message, /repoUrl/, '错误必须点明 repoUrl');
1761
+ assert.ok(err.message.includes('[REPO_URL]'), '错误消息里的地址必须脱敏');
1762
+ assert.ok(!err.message.includes('169.254.169.254'), '不得把危险地址原样回显');
1763
+ return true;
1764
+ },
1765
+ );
1766
+ assert.equal(calls.length, 0, '构造期拦截,不得先 spawn 再失败: ' + repoUrl);
1767
+ }
1768
+
1769
+ // 本机路径仍是 git 通道既有的合法目标(real-git 集成测试就靠它),不得被白名单误伤
1770
+ const local = new GitTransport(makeOptions({ repoUrl: path.join(dir, 'remote.git'), workDir: dir, exec: mockExec([]) }));
1771
+ assert.ok(local);
1772
+ });
1773
+
1774
+ test('SEC-12:协议护栏 —— 网络地址恒禁 ext + file;本机路径只禁 ext(否则本地仓老用法失效)', async (t) => {
1775
+ const net = await makeTempDir(t);
1776
+ const netCalls: CallRecord[] = [];
1777
+ const netTransport = new GitTransport(makeOptions({ workDir: net, exec: mockExec(netCalls) }));
1778
+ await netTransport.list();
1779
+ assert.ok(netCalls.length > 0, '应有 git 调用');
1780
+ for (const c of netCalls) {
1781
+ assert.ok(hasGitConfig(c.args, 'protocol.ext.allow=never'), '每条 git 都必须禁 ext 兜底(ext:: 可执行任意命令): ' + c.args.join(' '));
1782
+ assert.ok(hasGitConfig(c.args, 'protocol.file.allow=never'), '网络地址还须禁 file(防被改写成 file:// 后读本机仓): ' + c.args.join(' '));
1783
+ }
1784
+
1785
+ const localDir = await makeTempDir(t);
1786
+ const localCalls: CallRecord[] = [];
1787
+ const localTransport = new GitTransport(makeOptions({
1788
+ repoUrl: path.join(localDir, 'remote.git'), workDir: localDir, exec: mockExec(localCalls),
1789
+ }));
1790
+ await localTransport.list();
1791
+ assert.ok(localCalls.length > 0, '应有 git 调用');
1792
+ for (const c of localCalls) {
1793
+ assert.ok(hasGitConfig(c.args, 'protocol.ext.allow=never'), 'ext 护栏对任何地址都开: ' + c.args.join(' '));
1794
+ assert.ok(!hasGitConfig(c.args, 'protocol.file.allow=never'), '本机路径不得禁 file(否则 clone 本地仓直接失败): ' + c.args.join(' '));
1795
+ }
1796
+ });
1797
+
1798
+ test('SEC-12:repoUrl 恒由 -- 与选项分开(clone / ls-remote / remote add)', async (t) => {
1799
+ const dir = await makeTempDir(t);
1800
+ const calls: CallRecord[] = [];
1801
+ const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
1802
+ await transport.list();
1803
+
1804
+ const urlArgIndex = (args: string[]): number => args.indexOf('https://github.com/example/private-config.git');
1805
+ const withUrl = calls.filter((c) => urlArgIndex(c.args) >= 0);
1806
+ assert.ok(withUrl.length > 0, '应有带 repoUrl 的调用: ' + calls.map((c) => c.args.join(' ')).join(' | '));
1807
+ for (const c of withUrl) {
1808
+ const i = urlArgIndex(c.args);
1809
+ assert.equal(c.args[i - 1], '--', 'repoUrl 前必须有 -- 结束选项解析: ' + c.args.join(' '));
1810
+ }
1811
+
1812
+ // remote add 同样受约束(工作副本已存在但 origin 读不到)
1813
+ const wd = await makeGitWorkDir(t);
1814
+ const remotes: CallRecord[] = [];
1815
+ const t2 = new GitTransport(makeOptions({ workDir: wd, exec: probeExec(remotes, null) }));
1816
+ await t2.list();
1817
+ const add = remotes.filter((c) => c.args.includes('add'));
1818
+ assert.equal(add.length, 1, '应 remote add: ' + remotes.map((c) => c.args.join(' ')).join(' | '));
1819
+ const ai = add[0]!.args.indexOf('https://github.com/example/private-config.git');
1820
+ assert.equal(add[0]!.args[ai - 1], '--', 'remote add 的 repoUrl 前必须有 --');
1821
+ });
1822
+
1823
+ /* ------------------------------------------------------------------------------------------------
1824
+ * RV1-F8:错误消息与返回体不得回显真 repoUrl
1825
+ *
1826
+ * 事实:构造期已用 `[REPO_URL]` 占位符,但 `repoHost()` 的 catch、`cmdFailed` 的 `{args}`(argv 里
1827
+ * 原样带着 repoUrl)、以及 `inspectWorkDir()` 回传的 origin/pushUrl 仍可能把 `user:token@` 带出去 ——
1828
+ * 这些字符串会进日志、进 GET /sync/status 响应、进用户贴出来的 issue 截图。
1829
+ * 口径:错误/日志一律占位符(mask),返回体保留 host/path 但剥掉 userinfo。
1830
+ * ---------------------------------------------------------------------------------------------- */
1831
+ test('RV1-F8:错误文本与工作副本自检返回体都不含 userinfo(token 一条都不许出)', async (t) => {
1832
+ const secret = 'ghp_leaktoken0123456789';
1833
+ const secretUrl = 'ssh://user:' + secret + '@git.example.com/u/r.git';
1834
+
1835
+ // ① exec 抛错 → cmdFailed 的 {args} 含 repoUrl 原文,必须经 mask
1836
+ const dir = await makeTempDir(t);
1837
+ const throwing: GitExecFn = async (_cmd, args): Promise<GitExecResult> => {
1838
+ if (args.includes('ls-remote')) throw new Error('spawn git ENOENT: ' + args.join(' '));
1839
+ return { stdout: '', stderr: '', code: 0 };
1840
+ };
1841
+ const transport = new GitTransport(makeOptions({ repoUrl: secretUrl, workDir: dir, exec: throwing }));
1842
+ let raised: unknown = null;
1843
+ try {
1844
+ await transport.checkIsPrivate();
1845
+ } catch (err) {
1846
+ raised = err;
1847
+ }
1848
+ assert.ok(raised instanceof GitTransportError, '必须归一为 GitTransportError');
1849
+ const msg1 = (raised as GitTransportError).message;
1850
+ assert.ok(msg1.includes('[REPO_URL]'), '回显必须走占位符: ' + msg1);
1851
+ assert.ok(!msg1.includes(secret), 'argv 里的地址含 token —— 错误文本不得回显: ' + msg1);
1852
+ assert.ok(!msg1.includes('user:'), '不得回显 userinfo 片段: ' + msg1);
1853
+
1854
+ // ② 工作副本自检的 origin / pushUrl 会进 GET /sync/status 响应 → 只剥 userinfo,保留 host/path
1855
+ const wd = await makeGitWorkDir(t);
1856
+ const calls: CallRecord[] = [];
1857
+ const inspector = new GitTransport(makeOptions({ repoUrl: secretUrl, workDir: wd, exec: probeExec(calls, secretUrl) }));
1858
+ const facts = await inspector.inspectWorkDir();
1859
+ assert.equal(facts.origin, 'ssh://git.example.com/u/r.git', 'origin 必须剥掉 userinfo: ' + String(facts.origin));
1860
+ assert.equal(facts.pushUrl, 'ssh://git.example.com/u/r.git', 'pushUrl 同样剥掉 userinfo: ' + String(facts.pushUrl));
1861
+ assert.ok(!JSON.stringify(facts).includes(secret), 'facts 里任何字段都不得残留 token');
1862
+ assert.equal(facts.match, true, '匹配判定用原文比对,不受脱敏影响');
1863
+
1864
+ // ③ 地址解析不出主机时(本机路径走 repoHost),错误里也只是占位符
1865
+ const localDir = await makeTempDir(t);
1866
+ const localTransport = new GitTransport(makeOptions({
1867
+ repoUrl: path.join(localDir, 'remote.git'), workDir: localDir, exec: mockExec([]),
1868
+ }));
1869
+ let hostErr: unknown = null;
1870
+ try {
1871
+ // 私有可见性探测会走 buildCredentialArgs → repoHost();本机路径 new URL() 必失败
1872
+ await localTransport.checkIsPrivate();
1873
+ } catch (err) {
1874
+ hostErr = err;
1875
+ }
1876
+ if (hostErr instanceof GitTransportError) {
1877
+ assert.ok(!hostErr.message.includes(localDir), '本机路径同样不得原样回显: ' + hostErr.message);
1878
+ }
1879
+ });