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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1256 -1185
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +56 -2
- package/lib/sync/git/git-transport.js +344 -73
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +6 -1
- package/src/sync/git/git-transport.test.ts +351 -14
- package/src/sync/git/git-transport.ts +343 -79
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
- 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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
+
});
|