dsh-config-manager 0.1.12 → 0.1.13
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 +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-git-channel:Git 私有仓库通道(SyncTransport 的 git 实现)。
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* - 快照以「散文件目录」提交到 git 仓库:工作副本 <workDir>/snapshots/<id>/ 即 t2 layout 布局
|
|
6
|
+
* (manifest.json + 平铺 JSON 分区 + 文件类分区目录),每次 sync 一次 commit + push。
|
|
7
|
+
* - 命令执行走 node:child_process execFile(promise 封装,数组参数无 shell 注入),
|
|
8
|
+
* 默认 gitBin='git',可注入 exec 便于测试。
|
|
9
|
+
* - 认证:token 仅从注入的 credentials provider 读取(每次网络操作时 getToken()),
|
|
10
|
+
* 经 git credential helper(store --file=<临时文件>)传给 git —— token 不进入 argv、
|
|
11
|
+
* 不进入 repoUrl、不写入任何同步内容/commit message/日志;临时凭据文件用后即删。
|
|
12
|
+
* - 私有仓库判定:checkIsPrivate() 先匿名 ls-remote(成功=公开),失败再用凭据探测
|
|
13
|
+
* (成功=私有),isPrivateHint 缓存最近结果供 UI 提示(私有仓库为推荐使用场景)。
|
|
14
|
+
* - 契约:同 id 重复 upload = 覆盖(幂等友好:内容无变化时不产生 commit);download 不存在抛错;
|
|
15
|
+
* delete 不存在视为成功。
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
|
|
23
|
+
import { createSnapshotFs, joinFs } from '../fs.ts';
|
|
24
|
+
import type { SnapshotFs } from '../fs.ts';
|
|
25
|
+
import { readSnapshotFromDir, SNAPSHOT_MANIFEST_FILE, writeSnapshotToDir } from '../layout.ts';
|
|
26
|
+
import type { SnapshotDirManifest } from '../layout.ts';
|
|
27
|
+
import { computeSnapshotMeta } from '../transport.ts';
|
|
28
|
+
import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from '../transport.ts';
|
|
29
|
+
import { parseJsonSafe } from '../../utils/json.ts';
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
|
|
33
|
+
/** git 命令执行结果(code ≠ 0 = git 命令失败/退出非零) */
|
|
34
|
+
export interface GitExecResult {
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
code: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 可注入的 git 执行器(测试 mock 用);默认实现 = child_process.execFile promise 封装 */
|
|
41
|
+
export type GitExecFn = (
|
|
42
|
+
cmd: string,
|
|
43
|
+
args: string[],
|
|
44
|
+
opts: { cwd?: string; timeoutMs?: number },
|
|
45
|
+
) => Promise<GitExecResult>;
|
|
46
|
+
|
|
47
|
+
/** 凭据提供者:token 只从这里读取,绝不在任何地方持久化 */
|
|
48
|
+
export interface GitCredentialProvider {
|
|
49
|
+
getToken(): Promise<string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GitAuthor { name: string; email: string; }
|
|
53
|
+
|
|
54
|
+
export interface GitTransportOptions {
|
|
55
|
+
/** 远端私有仓库地址(https/ssh/本地路径);token 绝不拼入此 URL */
|
|
56
|
+
repoUrl: string;
|
|
57
|
+
/** 本地 git 工作副本目录(不存在则创建;已 clone 则复用) */
|
|
58
|
+
workDir: string;
|
|
59
|
+
/** token 提供者(http(s) 远端必填;本地/ssh 远端不会被调用) */
|
|
60
|
+
credentials: GitCredentialProvider;
|
|
61
|
+
/** git 可执行文件名/路径,默认 'git' */
|
|
62
|
+
gitBin?: string;
|
|
63
|
+
/** 单条 git 命令超时 ms,默认 60000 */
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
/** 注入 exec(测试 mock 用);缺省 = execFile 封装 */
|
|
66
|
+
exec?: GitExecFn;
|
|
67
|
+
/** 提交作者(写入远端历史),默认 DSH Config Sync <sync@dsh.local> */
|
|
68
|
+
author?: GitAuthor;
|
|
69
|
+
/** credential 用户名(GitHub PAT 用 oauth2),默认 'oauth2' */
|
|
70
|
+
credentialUsername?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class GitTransportError extends Error {
|
|
74
|
+
constructor(message: string) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'GitTransportError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SNAPSHOTS_REL = 'snapshots';
|
|
81
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
82
|
+
const DEFAULT_AUTHOR: GitAuthor = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
|
|
83
|
+
const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
|
|
84
|
+
/** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
|
|
85
|
+
const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
86
|
+
|
|
87
|
+
const defaultExec: GitExecFn = async (cmd, args, opts) => {
|
|
88
|
+
try {
|
|
89
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
90
|
+
cwd: opts.cwd,
|
|
91
|
+
timeout: opts.timeoutMs,
|
|
92
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
93
|
+
windowsHide: true,
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
});
|
|
96
|
+
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
99
|
+
return {
|
|
100
|
+
stdout: String(e.stdout ?? ''),
|
|
101
|
+
stderr: String(e.stderr ?? (err instanceof Error ? err.message : String(err))),
|
|
102
|
+
code: typeof e.code === 'number' ? e.code : 1,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** git config 值里的路径转义:含空白/引号时用引号包裹(Windows 路径转正斜杠) */
|
|
108
|
+
function quoteGitValue(value: string): string {
|
|
109
|
+
return /[\s"']/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 实现 SyncTransport 的 git 通道。所有操作前 ensureRepo() 保证工作副本就绪,网络命令带凭据。 */
|
|
113
|
+
export class GitTransport implements SyncTransport {
|
|
114
|
+
readonly type = 'git';
|
|
115
|
+
|
|
116
|
+
private readonly o: GitTransportOptions & {
|
|
117
|
+
gitBin: string;
|
|
118
|
+
timeoutMs: number;
|
|
119
|
+
author: GitAuthor;
|
|
120
|
+
credentialUsername: string;
|
|
121
|
+
exec: GitExecFn;
|
|
122
|
+
};
|
|
123
|
+
private repoReady = false;
|
|
124
|
+
private privateHint: boolean | null = null;
|
|
125
|
+
|
|
126
|
+
constructor(options: GitTransportOptions) {
|
|
127
|
+
if (typeof options.repoUrl !== 'string' || options.repoUrl.length === 0) {
|
|
128
|
+
throw new GitTransportError('repoUrl 必须是非空字符串');
|
|
129
|
+
}
|
|
130
|
+
if (typeof options.workDir !== 'string' || options.workDir.length === 0) {
|
|
131
|
+
throw new GitTransportError('workDir 必须是非空字符串');
|
|
132
|
+
}
|
|
133
|
+
if (options.credentials === null || typeof options.credentials !== 'object'
|
|
134
|
+
|| typeof options.credentials.getToken !== 'function') {
|
|
135
|
+
throw new GitTransportError('credentials 必须提供 getToken()');
|
|
136
|
+
}
|
|
137
|
+
this.o = {
|
|
138
|
+
repoUrl: options.repoUrl,
|
|
139
|
+
workDir: options.workDir,
|
|
140
|
+
credentials: options.credentials,
|
|
141
|
+
gitBin: options.gitBin ?? 'git',
|
|
142
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
143
|
+
exec: options.exec ?? defaultExec,
|
|
144
|
+
author: options.author ?? DEFAULT_AUTHOR,
|
|
145
|
+
credentialUsername: options.credentialUsername ?? DEFAULT_CREDENTIAL_USERNAME,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 最近一次 checkIsPrivate() 的结果;null = 尚未检查 */
|
|
150
|
+
get isPrivateHint(): boolean | null {
|
|
151
|
+
return this.privateHint;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 列出远端已有快照(按 createdAt 升序)。读取工作副本 snapshots/ 目录(先 pull 同步远端)。 */
|
|
155
|
+
async list(): Promise<SyncSnapshotMeta[]> {
|
|
156
|
+
await this.ensureRepo();
|
|
157
|
+
await this.pullFromRemote();
|
|
158
|
+
const fsx = createSnapshotFs();
|
|
159
|
+
const snapsAbs = this.snapshotsDir();
|
|
160
|
+
const names = await fsx.readdir(snapsAbs);
|
|
161
|
+
const metas: SyncSnapshotMeta[] = [];
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
if (name === '' || name === '.' || name === '..') continue;
|
|
164
|
+
const dirAbs = joinFs(snapsAbs, name);
|
|
165
|
+
if (!(await fsx.isDir(dirAbs))) continue;
|
|
166
|
+
const m = await this.readDirManifest(dirAbs);
|
|
167
|
+
if (m === null) continue; // 损坏/不完整 → 跳过(防御工作副本污染,不静默失败整体 list)
|
|
168
|
+
metas.push({
|
|
169
|
+
id: m.id,
|
|
170
|
+
createdAt: m.createdAt,
|
|
171
|
+
sections: m.sectionHashes,
|
|
172
|
+
manifest: m.manifest,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
metas.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
|
|
176
|
+
return metas;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 上传快照:写散文件目录 → add → commit → push;同 id 覆盖;内容无变化时幂等(不产生 commit)。 */
|
|
180
|
+
async upload(snapshot: SyncSnapshot): Promise<SyncSnapshotMeta> {
|
|
181
|
+
this.assertSafeId(snapshot.id);
|
|
182
|
+
await this.ensureRepo();
|
|
183
|
+
await this.pullFromRemote();
|
|
184
|
+
const fsx = createSnapshotFs();
|
|
185
|
+
const dir = this.snapshotDir(snapshot.id);
|
|
186
|
+
const existed = await fsx.exists(dir);
|
|
187
|
+
if (existed) await fsx.remove(dir); // 覆盖语义:先清旧目录,避免残留旧文件
|
|
188
|
+
await writeSnapshotToDir(snapshot, dir, fsx);
|
|
189
|
+
const rel = `${SNAPSHOTS_REL}/${snapshot.id}`;
|
|
190
|
+
await this.runGit(['add', '--', rel]);
|
|
191
|
+
const diff = await this.runGit(['diff', '--cached', '--quiet'], { allowNonZero: true });
|
|
192
|
+
if (diff.code !== 0) {
|
|
193
|
+
const verb = existed ? 'update' : 'add';
|
|
194
|
+
await this.runGit(['commit', '-m', `sync: ${verb} snapshot ${snapshot.id}`]);
|
|
195
|
+
await this.runGit(['push', '-u', 'origin', 'HEAD'], { withCredential: true });
|
|
196
|
+
}
|
|
197
|
+
return computeSnapshotMeta(snapshot);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 下载快照完整载荷(读回散文件目录)。不存在的 id 必须抛错(契约)。 */
|
|
201
|
+
async download(id: string): Promise<SyncSnapshot> {
|
|
202
|
+
this.assertSafeId(id);
|
|
203
|
+
await this.ensureRepo();
|
|
204
|
+
await this.pullFromRemote();
|
|
205
|
+
const dir = this.snapshotDir(id);
|
|
206
|
+
if (!(await createSnapshotFs().isDir(dir))) {
|
|
207
|
+
throw new GitTransportError(`快照 ${id} 不存在(本地工作副本 ${dir})`);
|
|
208
|
+
}
|
|
209
|
+
return readSnapshotFromDir(dir);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 删除远端快照(不存在视为成功,契约)。 */
|
|
213
|
+
async delete(id: string): Promise<void> {
|
|
214
|
+
this.assertSafeId(id);
|
|
215
|
+
await this.ensureRepo();
|
|
216
|
+
await this.pullFromRemote();
|
|
217
|
+
const fsx = createSnapshotFs();
|
|
218
|
+
const dir = this.snapshotDir(id);
|
|
219
|
+
if (!(await fsx.exists(dir))) return; // 不存在视为成功
|
|
220
|
+
await fsx.remove(dir);
|
|
221
|
+
const rel = `${SNAPSHOTS_REL}/${id}`;
|
|
222
|
+
await this.runGit(['add', '-A', '--', rel]);
|
|
223
|
+
const diff = await this.runGit(['diff', '--cached', '--quiet'], { allowNonZero: true });
|
|
224
|
+
if (diff.code !== 0) {
|
|
225
|
+
await this.runGit(['commit', '-m', `sync: delete snapshot ${id}`]);
|
|
226
|
+
await this.runGit(['push', '-u', 'origin', 'HEAD'], { withCredential: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 私有仓库可见性探测(结果缓存于 isPrivateHint):
|
|
232
|
+
* 匿名 ls-remote 成功 → 公开;匿名失败 + 凭据探测成功 → 私有;两者都失败 → 抛错。
|
|
233
|
+
* UI 层可用 isPrivateHint 提示「私有仓库推荐」;公开仓库也可用(内容需自行评估)。
|
|
234
|
+
*/
|
|
235
|
+
async checkIsPrivate(): Promise<boolean> {
|
|
236
|
+
if (this.privateHint !== null) return this.privateHint;
|
|
237
|
+
const anon = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
|
|
238
|
+
if (anon.code === 0) {
|
|
239
|
+
this.privateHint = false;
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
const authed = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
|
|
243
|
+
if (authed.code === 0) {
|
|
244
|
+
this.privateHint = true;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
throw new GitTransportError(`仓库不可达或认证失败: ${this.mask(this.o.repoUrl, null)}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ---------------- 内部实现 ---------------- */
|
|
251
|
+
|
|
252
|
+
private async ensureRepo(): Promise<void> {
|
|
253
|
+
if (this.repoReady) return;
|
|
254
|
+
const fsx = createSnapshotFs();
|
|
255
|
+
await fsx.mkdir(this.o.workDir); // 不存在则创建
|
|
256
|
+
if (await fsx.exists(path.join(this.o.workDir, '.git'))) {
|
|
257
|
+
this.repoReady = true;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const entries = await fsx.readdir(this.o.workDir);
|
|
261
|
+
if (entries.length > 0) {
|
|
262
|
+
throw new GitTransportError(`workDir 不是 git 仓库(缺少 .git)且目录非空,无法 clone: ${this.o.workDir}`);
|
|
263
|
+
}
|
|
264
|
+
await this.runGit(['clone', this.o.repoUrl, '.'], { cwd: this.o.workDir, withCredential: true });
|
|
265
|
+
// 仓库级提交身份(写入远端历史,可经 author 选项覆盖;不改全局配置)
|
|
266
|
+
await this.runGit(['config', 'user.name', this.o.author.name]);
|
|
267
|
+
await this.runGit(['config', 'user.email', this.o.author.email]);
|
|
268
|
+
this.repoReady = true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** pull --ff-only 同步远端;无本地提交(全新仓库)或无 upstream 时静默跳过 */
|
|
272
|
+
private async pullFromRemote(): Promise<void> {
|
|
273
|
+
const head = await this.runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], { allowNonZero: true });
|
|
274
|
+
if (head.code !== 0) return; // 无本地提交 → 无可 pull
|
|
275
|
+
const res = await this.runGit(['pull', '--ff-only'], { withCredential: true, allowNonZero: true });
|
|
276
|
+
if (res.code === 0) return;
|
|
277
|
+
if (/no tracking information/i.test(res.stderr)) return; // 无 upstream(初始状态)→ 跳过
|
|
278
|
+
throw new GitTransportError(`git pull 失败: ${this.mask(res.stderr, await this.readTokenOnce())}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 执行 git 命令;withCredential=true 时注入 credential helper(token 不进 argv),失败时错误消息脱敏 */
|
|
282
|
+
private async runGit(
|
|
283
|
+
args: string[],
|
|
284
|
+
opts: { cwd?: string; withCredential?: boolean; allowNonZero?: boolean } = {},
|
|
285
|
+
): Promise<GitExecResult> {
|
|
286
|
+
const cwd = opts.cwd === undefined ? this.o.workDir : opts.cwd;
|
|
287
|
+
let extra: string[] = [];
|
|
288
|
+
let token: string | null = null;
|
|
289
|
+
let cleanup: (() => Promise<void>) | null = null;
|
|
290
|
+
if (opts.withCredential) {
|
|
291
|
+
const cred = await this.buildCredentialArgs();
|
|
292
|
+
extra = cred.extraArgs;
|
|
293
|
+
token = cred.token;
|
|
294
|
+
cleanup = cred.cleanup;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
|
|
298
|
+
if (result.code !== 0 && !opts.allowNonZero) {
|
|
299
|
+
throw new GitTransportError(
|
|
300
|
+
this.mask(`git ${args.join(' ')} 失败 (exit ${result.code}): ${result.stderr}`, token),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
} finally {
|
|
305
|
+
if (cleanup) await cleanup();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 为网络命令构造 credential helper 参数:
|
|
311
|
+
* 仅 http(s) 远端需要 token;本地路径 / ssh 走 git 原生认证(密钥),不调用 provider。
|
|
312
|
+
* token 写入 os.tmpdir() 下临时 store 文件(权限 0600),命令后立即删除 —— 永不落工作副本/远端。
|
|
313
|
+
*/
|
|
314
|
+
private async buildCredentialArgs(): Promise<{ extraArgs: string[]; token: string; cleanup: () => Promise<void> }> {
|
|
315
|
+
if (!/^https?:\/\//i.test(this.o.repoUrl)) {
|
|
316
|
+
return { extraArgs: [], token: '', cleanup: async () => {} };
|
|
317
|
+
}
|
|
318
|
+
const token = await this.o.credentials.getToken();
|
|
319
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-git-cred-'));
|
|
320
|
+
const credFile = path.join(tmpDir, 'credential');
|
|
321
|
+
const host = this.repoHost();
|
|
322
|
+
const fileArg = quoteGitValue(credFile.replace(/\\/g, '/'));
|
|
323
|
+
await fs.writeFile(credFile, `https://${this.o.credentialUsername}:${token}@${host}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
324
|
+
return {
|
|
325
|
+
extraArgs: ['-c', 'credential.helper=', '-c', `credential.helper=store --file=${fileArg}`],
|
|
326
|
+
token,
|
|
327
|
+
cleanup: async () => { await fs.rm(tmpDir, { recursive: true, force: true }); },
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 从 repoUrl 提取 credential 匹配用的 host[:port] */
|
|
332
|
+
private repoHost(): string {
|
|
333
|
+
try {
|
|
334
|
+
const u = new URL(this.o.repoUrl);
|
|
335
|
+
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
|
336
|
+
} catch {
|
|
337
|
+
throw new GitTransportError(`无法解析 repoUrl(http(s) 仓库必须为合法 URL): ${this.o.repoUrl}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 读取一次 token(错误消息脱敏用);非 http(s) 场景返回空 */
|
|
342
|
+
private async readTokenOnce(): Promise<string | null> {
|
|
343
|
+
if (!/^https?:\/\//i.test(this.o.repoUrl)) return null;
|
|
344
|
+
try { return await this.o.credentials.getToken(); } catch { return null; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 错误/日志脱敏:token(原文与 URL 编码两种形态)与 repoUrl 一律替换 */
|
|
348
|
+
private mask(text: string, token: string | null): string {
|
|
349
|
+
let out = text.split(this.o.repoUrl).join('[REPO_URL]');
|
|
350
|
+
if (token && token.length >= 4) {
|
|
351
|
+
out = out.split(token).join('[REDACTED]').split(encodeURIComponent(token)).join('[REDACTED]');
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private assertSafeId(id: string): void {
|
|
357
|
+
if (typeof id !== 'string' || !SAFE_ID_RE.test(id)) {
|
|
358
|
+
throw new GitTransportError(`非法快照 id: ${JSON.stringify(id)}(仅允许字母数字开头,字符限 . _ -)`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private snapshotDir(id: string): string {
|
|
363
|
+
return joinFs(this.snapshotsDir(), id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private snapshotsDir(): string {
|
|
367
|
+
return joinFs(this.o.workDir, SNAPSHOTS_REL);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 读快照目录 manifest.json(结构不合法 → null,调用方跳过) */
|
|
371
|
+
private async readDirManifest(dir: string): Promise<SnapshotDirManifest | null> {
|
|
372
|
+
const fsx: SnapshotFs = createSnapshotFs();
|
|
373
|
+
try {
|
|
374
|
+
const abs = joinFs(dir, SNAPSHOT_MANIFEST_FILE);
|
|
375
|
+
if (!(await fsx.exists(abs))) return null;
|
|
376
|
+
const raw = Buffer.from(await fsx.readFile(abs)).toString('utf8');
|
|
377
|
+
const parsed = parseJsonSafe(raw);
|
|
378
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
379
|
+
const m = parsed as Record<string, unknown>;
|
|
380
|
+
if (typeof m['id'] !== 'string' || typeof m['createdAt'] !== 'string'
|
|
381
|
+
|| m['manifest'] === null || typeof m['manifest'] !== 'object'
|
|
382
|
+
|| m['sectionHashes'] === null || typeof m['sectionHashes'] !== 'object') {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
return parsed as SnapshotDirManifest;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-transport:散文件目录布局测试。
|
|
3
|
+
* - writeSnapshotToDir / readSnapshotFromDir 往返(JSON 分区平铺 + 文件类分区目录)
|
|
4
|
+
* - manifest.json 内容正确性
|
|
5
|
+
* - 安全:路径穿越拒绝、不支持分区拒绝、缺 manifest 拒绝、缺文件拒绝
|
|
6
|
+
* - 注入验证:内存 SnapshotFs 驱动同一逻辑
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { readSnapshotFromDir, writeSnapshotToDir, SNAPSHOT_MANIFEST_FILE, isSafeRelPath, listSnapshotFiles } from './layout.ts';
|
|
15
|
+
import { createSnapshotFs } from './fs.ts';
|
|
16
|
+
import { hashSection } from './sync-state.ts';
|
|
17
|
+
import type { SnapshotFs } from './fs.ts';
|
|
18
|
+
import type { SyncSnapshot } from './transport.ts';
|
|
19
|
+
|
|
20
|
+
function sampleSnapshot(): SyncSnapshot {
|
|
21
|
+
return {
|
|
22
|
+
id: 'snap-001',
|
|
23
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
24
|
+
manifest: {
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
dshVersion: '1.2.3',
|
|
27
|
+
platform: 'win32',
|
|
28
|
+
sectionIds: ['settings', 'providers', 'skills', 'pluginFiles'],
|
|
29
|
+
containsSecrets: false,
|
|
30
|
+
},
|
|
31
|
+
sections: {
|
|
32
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
33
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
34
|
+
skills: {
|
|
35
|
+
version: 1,
|
|
36
|
+
files: [
|
|
37
|
+
{ relativePath: 'coding.md', data: new TextEncoder().encode('# Coding\n'), contentHash: 'x' },
|
|
38
|
+
{ relativePath: 'nested/tool.md', data: new TextEncoder().encode('# Tool\n'), contentHash: 'y' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
pluginFiles: { version: 1, files: [{ relativePath: 'dsh-ssh/main.js', data: new TextEncoder().encode('module.exports = 1\n'), contentHash: 'z' }] },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertBytesEqual(actual: Uint8Array, expected: Uint8Array, msg?: string) {
|
|
47
|
+
assert.equal(Buffer.from(actual).equals(Buffer.from(expected)), true, msg ?? `字节不一致: ${Buffer.from(expected).toString('utf8')}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('isSafeRelPath: 拒绝穿越/绝对路径/反斜杠,接受常规相对路径', () => {
|
|
51
|
+
assert.equal(isSafeRelPath('a/b/c.md'), true);
|
|
52
|
+
assert.equal(isSafeRelPath('编码.md'), true);
|
|
53
|
+
assert.equal(isSafeRelPath('../evil.md'), false);
|
|
54
|
+
assert.equal(isSafeRelPath('a/../../evil.md'), false);
|
|
55
|
+
assert.equal(isSafeRelPath('/abs/path.md'), false);
|
|
56
|
+
assert.equal(isSafeRelPath('C:\\evil.md'), false);
|
|
57
|
+
assert.equal(isSafeRelPath('a\\b.md'), false);
|
|
58
|
+
assert.equal(isSafeRelPath(''), false);
|
|
59
|
+
assert.equal(isSafeRelPath('.'), false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('writeSnapshotToDir + readSnapshotFromDir: 完整往返(JSON 平铺 + 文件目录)', async () => {
|
|
63
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-'));
|
|
64
|
+
try {
|
|
65
|
+
const snap = sampleSnapshot();
|
|
66
|
+
const manifest = await writeSnapshotToDir(snap, tmp);
|
|
67
|
+
|
|
68
|
+
// 布局:manifest.json + config/settings.json + ai/providers.json + custom/skills/… + plugin-files/…
|
|
69
|
+
assert.equal(manifest.id, snap.id);
|
|
70
|
+
assert.ok((await fs.stat(path.join(tmp, SNAPSHOT_MANIFEST_FILE))).isFile());
|
|
71
|
+
assert.ok((await fs.stat(path.join(tmp, 'config', 'settings.json'))).isFile());
|
|
72
|
+
assert.ok((await fs.stat(path.join(tmp, 'ai', 'providers.json'))).isFile());
|
|
73
|
+
assert.equal((await fs.readFile(path.join(tmp, 'custom', 'skills', 'coding.md'), 'utf8')), '# Coding\n');
|
|
74
|
+
assert.equal((await fs.readFile(path.join(tmp, 'custom', 'skills', 'nested', 'tool.md'), 'utf8')), '# Tool\n');
|
|
75
|
+
assert.equal((await fs.readFile(path.join(tmp, 'plugin-files', 'dsh-ssh', 'main.js'), 'utf8')), 'module.exports = 1\n');
|
|
76
|
+
|
|
77
|
+
const back = await readSnapshotFromDir(tmp);
|
|
78
|
+
assert.equal(back.id, snap.id);
|
|
79
|
+
assert.equal(back.createdAt, snap.createdAt);
|
|
80
|
+
assert.deepEqual(back.manifest, snap.manifest);
|
|
81
|
+
assert.deepEqual(back.sections['settings'], snap.sections['settings']);
|
|
82
|
+
assert.deepEqual(back.sections['providers'], snap.sections['providers']);
|
|
83
|
+
const skills = back.sections['skills'] as { files: { relativePath: string; data: Uint8Array; contentHash: string }[] };
|
|
84
|
+
const skillsOrig = snap.sections['skills'] as { files: { relativePath: string; data: Uint8Array; contentHash: string }[] };
|
|
85
|
+
assert.equal(skills.files.length, 2);
|
|
86
|
+
assert.deepEqual(skills.files.map((f) => f.relativePath).sort(), ['coding.md', 'nested/tool.md']);
|
|
87
|
+
assertBytesEqual(skills.files.find((f) => f.relativePath === 'coding.md')!.data, skillsOrig.files[0]!.data);
|
|
88
|
+
// 读回内容 hash 与写入时 manifest 记录一致(完整性自洽)
|
|
89
|
+
assert.equal(hashSection(back.sections['skills']!), manifest.sectionHashes['skills']);
|
|
90
|
+
assert.equal(hashSection(back.sections['settings']!), manifest.sectionHashes['settings']);
|
|
91
|
+
} finally {
|
|
92
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('writeSnapshotToDir: manifest.sectionHashes 覆盖全部包含分区', async () => {
|
|
97
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-hash-'));
|
|
98
|
+
try {
|
|
99
|
+
const snap = sampleSnapshot();
|
|
100
|
+
const manifest = await writeSnapshotToDir(snap, tmp);
|
|
101
|
+
assert.deepEqual(Object.keys(manifest.sectionHashes).sort(), ['pluginFiles', 'providers', 'settings', 'skills']);
|
|
102
|
+
for (const [id, data] of Object.entries(snap.sections)) {
|
|
103
|
+
assert.equal(manifest.sectionHashes[id as keyof typeof snap.sections], hashSection(data));
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('writeSnapshotToDir: 空文件类分区也保留(目录存在 → 读回含空 files)', async () => {
|
|
111
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-empty-'));
|
|
112
|
+
try {
|
|
113
|
+
const snap = sampleSnapshot();
|
|
114
|
+
snap.sections['skills'] = { version: 1, files: [] };
|
|
115
|
+
await writeSnapshotToDir(snap, tmp);
|
|
116
|
+
const back = await readSnapshotFromDir(tmp);
|
|
117
|
+
assert.deepEqual(back.sections['skills'], { version: 1, files: [] });
|
|
118
|
+
} finally {
|
|
119
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('writeSnapshotToDir: 拒绝路径穿越的文件相对路径', async () => {
|
|
124
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-evil-'));
|
|
125
|
+
try {
|
|
126
|
+
const snap = sampleSnapshot();
|
|
127
|
+
snap.sections['skills'] = { version: 1, files: [{ relativePath: '../../evil.md', data: new TextEncoder().encode('x'), contentHash: '' }] };
|
|
128
|
+
await assert.rejects(() => writeSnapshotToDir(snap, tmp), /非法路径|relativePath|路径/);
|
|
129
|
+
// 绝对路径同样拒绝
|
|
130
|
+
snap.sections['skills'] = { version: 1, files: [{ relativePath: 'C:\\evil.md', data: new TextEncoder().encode('x'), contentHash: '' }] };
|
|
131
|
+
await assert.rejects(() => writeSnapshotToDir(snap, tmp));
|
|
132
|
+
} finally {
|
|
133
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('writeSnapshotToDir: 拒绝布局不支持的子分区(如 secrets)', async () => {
|
|
138
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-nosec-'));
|
|
139
|
+
try {
|
|
140
|
+
const snap = sampleSnapshot();
|
|
141
|
+
(snap.sections as Record<string, unknown>)['secrets'] = { version: 1, values: [] };
|
|
142
|
+
await assert.rejects(() => writeSnapshotToDir(snap, tmp), /不支持|secrets|布局/);
|
|
143
|
+
} finally {
|
|
144
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('readSnapshotFromDir: 目录缺 manifest.json → 拒绝', async () => {
|
|
149
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-nomani-'));
|
|
150
|
+
try {
|
|
151
|
+
await assert.rejects(() => readSnapshotFromDir(tmp), /manifest/);
|
|
152
|
+
} finally {
|
|
153
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('readSnapshotFromDir: manifest 声明了分区但文件缺失 → 拒绝(不静默降级)', async () => {
|
|
158
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-missing-'));
|
|
159
|
+
try {
|
|
160
|
+
await writeSnapshotToDir(sampleSnapshot(), tmp);
|
|
161
|
+
await fs.rm(path.join(tmp, 'config', 'settings.json'));
|
|
162
|
+
await assert.rejects(() => readSnapshotFromDir(tmp), /settings/);
|
|
163
|
+
} finally {
|
|
164
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('注入验证:内存 SnapshotFs 驱动同一往返逻辑', async () => {
|
|
169
|
+
const mem: Map<string, Uint8Array> = new Map();
|
|
170
|
+
const snap = sampleSnapshot();
|
|
171
|
+
const memFs: SnapshotFs = {
|
|
172
|
+
async readFile(p) {
|
|
173
|
+
const v = mem.get(p);
|
|
174
|
+
if (!v) throw new Error(`ENOENT: ${p}`);
|
|
175
|
+
return v;
|
|
176
|
+
},
|
|
177
|
+
async writeFile(p, d) { mem.set(p, new Uint8Array(d)); },
|
|
178
|
+
async mkdir(p) { mem.set(p + '/.dir', new Uint8Array()); }, // 目录哨兵
|
|
179
|
+
async readdir(p) {
|
|
180
|
+
const prefix = p.endsWith('/') ? p : p + '/';
|
|
181
|
+
const out = new Set<string>();
|
|
182
|
+
for (const k of mem.keys()) {
|
|
183
|
+
if (!k.startsWith(prefix)) continue;
|
|
184
|
+
const rest = k.slice(prefix.length);
|
|
185
|
+
if (rest === '' || rest === '.dir') continue;
|
|
186
|
+
out.add(rest.split('/')[0]!);
|
|
187
|
+
}
|
|
188
|
+
return [...out];
|
|
189
|
+
},
|
|
190
|
+
async isDir(p) {
|
|
191
|
+
if (mem.has(p + '/.dir')) return true;
|
|
192
|
+
for (const k of mem.keys()) if (k.startsWith(p + '/')) return true;
|
|
193
|
+
return false;
|
|
194
|
+
},
|
|
195
|
+
async exists(p) { return mem.has(p) || mem.has(p + '/.dir'); },
|
|
196
|
+
async remove(p) {
|
|
197
|
+
const prefix = p.endsWith('/') ? p : p + '/';
|
|
198
|
+
for (const k of [...mem.keys()]) if (k === p || k.startsWith(prefix)) mem.delete(k);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const manifest = await writeSnapshotToDir(snap, 'snap/001', memFs);
|
|
202
|
+
const back = await readSnapshotFromDir('snap/001', memFs);
|
|
203
|
+
assert.equal(back.id, snap.id);
|
|
204
|
+
assert.deepEqual(back.sections['settings'], snap.sections['settings']);
|
|
205
|
+
const skills = back.sections['skills'] as { files: { relativePath: string; data: Uint8Array; contentHash: string }[] };
|
|
206
|
+
assert.equal(skills.files.length, 2);
|
|
207
|
+
assert.equal(hashSection(back.sections['skills']!), manifest.sectionHashes['skills']);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('createSnapshotFs: 默认 node:fs 适配器可用(真实临时目录)', async () => {
|
|
211
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-layout-adapter-'));
|
|
212
|
+
try {
|
|
213
|
+
const fsx = createSnapshotFs();
|
|
214
|
+
await fsx.mkdir(path.join(tmp, 'a/b'));
|
|
215
|
+
await fsx.writeFile(path.join(tmp, 'a/b/x.md'), new TextEncoder().encode('hi'));
|
|
216
|
+
assert.equal(await fsx.exists(path.join(tmp, 'a/b/x.md')), true);
|
|
217
|
+
assert.equal(await fsx.isDir(path.join(tmp, 'a/b')), true);
|
|
218
|
+
assert.deepEqual(await fsx.readdir(path.join(tmp, 'a')), ['b']);
|
|
219
|
+
assert.equal(new TextDecoder().decode(await fsx.readFile(path.join(tmp, 'a/b/x.md'))), 'hi');
|
|
220
|
+
assert.deepEqual(await listSnapshotFiles(fsx, path.join(tmp, 'a')), ['b/x.md']);
|
|
221
|
+
await fsx.remove(path.join(tmp, 'a'));
|
|
222
|
+
assert.equal(await fsx.exists(path.join(tmp, 'a')), false);
|
|
223
|
+
} finally {
|
|
224
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|