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
package/src/sync/sync-config.ts
CHANGED
|
@@ -168,12 +168,26 @@ function parseV2WebDavNamespace(ns: unknown): WebDavConfig | null {
|
|
|
168
168
|
return webdav;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* 已落盘 repoUrl 的读取侧校验(SEC-01):与写入侧同一份 validateRepoUrl。
|
|
173
|
+
* 不合法 → 记一条明确 issue 并让调用方丢弃该配置;绝不把可疑地址继续交给凭据注入与 git 子进程。
|
|
174
|
+
*/
|
|
175
|
+
function acceptPersistedRepoUrl(repoUrl: string, issues?: SyncConfigIssue[]): boolean {
|
|
176
|
+
const err = validateRepoUrl(repoUrl);
|
|
177
|
+
if (err === null) return true;
|
|
178
|
+
issues?.push({ code: 'repo-url-invalid', channel: 'git', message: err });
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
171
182
|
/**
|
|
172
183
|
* 读取同步通道配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
|
|
173
184
|
* 兼容旧文件:缺 schemaVersion 字段视为 v1(git 通道)。
|
|
174
185
|
* 恒返回 schemaVersion=2 的规范形态(v1 读取时归一为 git)。
|
|
186
|
+
*
|
|
187
|
+
* `issues` 可选:传数组时,被丢弃的 git repoUrl(白名单校验不通过)以结构化成因追加进去,
|
|
188
|
+
* 供 UI/日志如实呈现(不静默吞掉)。
|
|
175
189
|
*/
|
|
176
|
-
export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
|
|
190
|
+
export async function readSyncConfig(dir: string, issues?: SyncConfigIssue[]): Promise<SyncConfig | null> {
|
|
177
191
|
const file = path.join(dir, SYNC_CONFIG_FILE);
|
|
178
192
|
let raw: string;
|
|
179
193
|
try {
|
|
@@ -200,6 +214,7 @@ export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
|
|
|
200
214
|
if (ver === 1) {
|
|
201
215
|
const git = parseV1Git(obj);
|
|
202
216
|
if (git === null) return null;
|
|
217
|
+
if (!acceptPersistedRepoUrl(git.repoUrl, issues)) return null;
|
|
203
218
|
return { schemaVersion: 2, transport: 'git', git };
|
|
204
219
|
}
|
|
205
220
|
|
|
@@ -210,6 +225,7 @@ export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
|
|
|
210
225
|
if (transport === 'git') {
|
|
211
226
|
const git = parseV2GitNamespace(obj['git']);
|
|
212
227
|
if (git === null) return null;
|
|
228
|
+
if (!acceptPersistedRepoUrl(git.repoUrl, issues)) return null;
|
|
213
229
|
return { schemaVersion: 2, transport: 'git', git };
|
|
214
230
|
}
|
|
215
231
|
if (transport === 'webdav') {
|
|
@@ -264,9 +280,14 @@ function readBothNamespaces(file: string): Promise<{ git?: GitConfig; webdav?: W
|
|
|
264
280
|
* 读取完整的双命名空间配置(供 status 路由回填另一通道的 repoUrl/url)。
|
|
265
281
|
* 文件不存在/损坏/无任何通道配置 → null(视为未配置)。
|
|
266
282
|
*/
|
|
267
|
-
export async function readFullSyncConfig(dir: string): Promise<FullSyncConfig | null> {
|
|
283
|
+
export async function readFullSyncConfig(dir: string, issues?: SyncConfigIssue[]): Promise<FullSyncConfig | null> {
|
|
268
284
|
const file = path.join(dir, SYNC_CONFIG_FILE)
|
|
269
285
|
const both = await readBothNamespaces(file)
|
|
286
|
+
// SEC-01/03 读侧:已落盘的 git repoUrl 过同一份白名单校验;不合法就当作没配 git 通道
|
|
287
|
+
// (自动同步/凭据注入都从这里取通道配置,绝不能让可疑地址继续往下走)
|
|
288
|
+
if (both.git !== undefined && !acceptPersistedRepoUrl(both.git.repoUrl, issues)) {
|
|
289
|
+
delete both.git
|
|
290
|
+
}
|
|
270
291
|
// 云端点通道的配置不在 sync-config.json(见 sync-cloud-config.json);这里一并对齐成完整视图
|
|
271
292
|
const s3 = await readS3ChannelConfig(dir)
|
|
272
293
|
const gist = await readGistChannelConfig(dir)
|
|
@@ -296,8 +317,12 @@ export async function readFullSyncConfig(dir: string): Promise<FullSyncConfig |
|
|
|
296
317
|
* 读取指定通道的同步通道配置(供自动同步调度器按通道运行)。
|
|
297
318
|
* 从完整双命名空间配置取对应通道构造可辨识联合 SyncConfig;该通道未配置 → null。
|
|
298
319
|
*/
|
|
299
|
-
export async function readSyncConfigFor(
|
|
300
|
-
|
|
320
|
+
export async function readSyncConfigFor(
|
|
321
|
+
dir: string,
|
|
322
|
+
channel: SyncTransportType,
|
|
323
|
+
issues?: SyncConfigIssue[],
|
|
324
|
+
): Promise<SyncConfig | null> {
|
|
325
|
+
const full = await readFullSyncConfig(dir, issues);
|
|
301
326
|
if (full === null) return null;
|
|
302
327
|
switch (channel) {
|
|
303
328
|
case 'git':
|
|
@@ -430,10 +455,135 @@ export async function clearSyncChannel(dir: string, channel: SyncTransportType):
|
|
|
430
455
|
return { removed: true, hasRemaining: true, transport };
|
|
431
456
|
}
|
|
432
457
|
|
|
458
|
+
/** repoUrl 形态:网络地址(http/https/ssh,含 scp 式)或本机路径 */
|
|
459
|
+
export type RepoUrlKind = 'http' | 'https' | 'ssh' | 'local';
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 已落盘 repoUrl 校验失败的**结构化**结果(SEC-01 读侧)。
|
|
463
|
+
* 读取侧不能只是返回 null:调用方要能如实告诉用户「你的配置被丢弃了,原因是地址不合法」。
|
|
464
|
+
*/
|
|
465
|
+
export interface SyncConfigIssue {
|
|
466
|
+
code: 'repo-url-invalid';
|
|
467
|
+
channel: SyncTransportType;
|
|
468
|
+
message: string;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* repoUrl 形态分类;null = 不认识的形态。
|
|
473
|
+
*
|
|
474
|
+
* 为什么要分类而不是只做正则:git 对同一个「地址」支持 http(s)、ssh、scp 式与本机路径,
|
|
475
|
+
* 而这三类的安全含义完全不同(网络地址要防 SSRF,本机路径要防 argv 注入)。先判形态,
|
|
476
|
+
* 再按形态分别收紧,比一堆互相打架的正则清楚。
|
|
477
|
+
*/
|
|
478
|
+
export function classifyRepoUrl(repoUrl: string): { kind: RepoUrlKind; host: string } | null {
|
|
479
|
+
const url = repoUrl.trim();
|
|
480
|
+
if (url === '' || /\s/.test(url)) return null;
|
|
481
|
+
// SEC-12:以 '-' 开头的「地址」会被 git 当成选项(--upload-pack=… / -c core.pager=…)
|
|
482
|
+
if (url.startsWith('-')) return null;
|
|
483
|
+
// 'ext::sh -c …' 一类命令协议(SCM 协议语法里 scheme 与地址之间是 '::')
|
|
484
|
+
if (url.includes('::')) return null;
|
|
485
|
+
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url);
|
|
486
|
+
if (scheme !== null) {
|
|
487
|
+
const name = scheme[1]!.toLowerCase();
|
|
488
|
+
if (name !== 'http' && name !== 'https' && name !== 'ssh') return null;
|
|
489
|
+
// 'https:///r.git' 这类空 authority 会被 URL 解析器「跳过斜杠」后当成主机 r.git —— 必须先拦
|
|
490
|
+
const rest = url.slice(scheme[0].length);
|
|
491
|
+
if (rest === '' || rest.startsWith('/') || rest.startsWith('\\')) return null;
|
|
492
|
+
let parsed: URL;
|
|
493
|
+
try {
|
|
494
|
+
parsed = new URL(url);
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
if (parsed.hostname === '') return null;
|
|
499
|
+
return { kind: name, host: parsed.hostname };
|
|
500
|
+
}
|
|
501
|
+
// scp 式(git@github.com:user/repo.git):只有 user@host:path 才算网络地址
|
|
502
|
+
const scp = /^[^\s/@:]+@([^\s/:]+):(.+)$/.exec(url);
|
|
503
|
+
if (scp !== null) return { kind: 'ssh', host: scp[1]! };
|
|
504
|
+
// 其余按本机路径处理(相对 ../bare、绝对 /home/u/r.git、盘符 D:/repos/bare)
|
|
505
|
+
return { kind: 'local', host: '' };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** 四段点分且每段 0-255 → IPv4 字面量;否则 null(域名里的数字段不算 IP) */
|
|
509
|
+
function parseIpv4(host: string): [number, number, number, number] | null {
|
|
510
|
+
const parts = host.split('.');
|
|
511
|
+
if (parts.length !== 4) return null;
|
|
512
|
+
const nums: number[] = [];
|
|
513
|
+
for (const part of parts) {
|
|
514
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
515
|
+
const n = Number(part);
|
|
516
|
+
if (n > 255) return null;
|
|
517
|
+
nums.push(n);
|
|
518
|
+
}
|
|
519
|
+
return [nums[0]!, nums[1]!, nums[2]!, nums[3]!];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 环回 / 私网 / 链路本地 / 未指定地址(含云元数据 169.254.169.254)判定。
|
|
524
|
+
* SEC-03:这类地址让「客户端」去访问内网或本机端口,等于给 SSRF 开一扇门。
|
|
525
|
+
*
|
|
526
|
+
* **已知行为变更(有意,非副作用)**:SEC-02/03 之前 `https://192.168.1.10/…`、`http://127.0.0.1:3000/…`、
|
|
527
|
+
* `http://nas.local/…` 这类地址可以被接受。收紧后一律拒绝 —— 代价是「拿家里 NAS / 局域网 git 服务器
|
|
528
|
+
* 当同步目标」这条路走不通,收益是「包里的 repoUrl 无法把家目录配置推给内网监听者或云元数据端点」。
|
|
529
|
+
* 取舍判据:本插件的同步目标是「用户自己的远端仓库」,公网 http(s)/ssh 已覆盖该用途;本机目录仓库
|
|
530
|
+
* 仍走 `local` 分支(不由本函数判定)。真要支持内网目标,应做成显式开关 + 用户逐目标确认,
|
|
531
|
+
* 不能靠「地址字符串看起来像内网就放行」——那正是攻击者可控的判据。
|
|
532
|
+
*/
|
|
533
|
+
export function isPrivateRepoHost(host: string): boolean {
|
|
534
|
+
const h = host.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '');
|
|
535
|
+
if (h === '') return true;
|
|
536
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
537
|
+
const v4 = parseIpv4(h);
|
|
538
|
+
if (v4 !== null) {
|
|
539
|
+
const [a, b] = v4;
|
|
540
|
+
return a === 0 || a === 127 || a === 10
|
|
541
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
542
|
+
|| (a === 192 && b === 168)
|
|
543
|
+
|| (a === 169 && b === 254);
|
|
544
|
+
}
|
|
545
|
+
if (!h.includes(':')) return false;
|
|
546
|
+
if (h === '::1' || h === '::') return true;
|
|
547
|
+
if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 唯一本地
|
|
548
|
+
if (/^fe[89ab][0-9a-f]?:/.test(h)) return true; // fe80::/10 链路本地
|
|
549
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
|
|
550
|
+
if (mapped !== null) return isPrivateRepoHost(mapped[1]!);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 文案里回显地址用:剥掉 userinfo(token 可能就藏在这一段),host/path 原样保留。
|
|
556
|
+
*
|
|
557
|
+
* 为什么不整串换成 `[REPO_URL]` 占位符:这里拒绝的是**用户自己刚填的**地址,他需要认出
|
|
558
|
+
* 「被拒的是哪一个」才能改;剥掉 `user:pass@` 之后剩下的部分正是修复所需的定位信息。
|
|
559
|
+
* 为什么解析失败也要剥:`https://user:tok@` 这类半截地址 `new URL` 会抛,但前缀照样是凭据。
|
|
560
|
+
*/
|
|
561
|
+
export function sanitizeRepoUrlForMessage(url: string): string {
|
|
562
|
+
const raw = url.trim();
|
|
563
|
+
try {
|
|
564
|
+
const parsed = new URL(raw);
|
|
565
|
+
if (parsed.username === '' && parsed.password === '') return raw;
|
|
566
|
+
parsed.username = '';
|
|
567
|
+
parsed.password = '';
|
|
568
|
+
return parsed.toString();
|
|
569
|
+
} catch {
|
|
570
|
+
// 只有「残缺的带 scheme 地址」(`https://user:tok@`,`new URL` 会抛但前缀就是凭据)需要剥。
|
|
571
|
+
// 无 scheme 的 scp 式(`git@host:path`)里 `git` 是 ssh 登录名、不是凭据 → 原样返回,
|
|
572
|
+
// 免得用户看到一条认不出是哪个远端的报错。
|
|
573
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) return raw;
|
|
574
|
+
return raw.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/@\s]*@/, (_m: string, scheme?: string) => scheme ?? '');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
433
578
|
/**
|
|
434
579
|
* 仓库地址合法性校验(返回错误消息;null = 合法)。
|
|
435
|
-
*
|
|
436
|
-
*
|
|
580
|
+
*
|
|
581
|
+
* 白名单口径(SEC-02/03/12,写入侧与读取侧共用同一份实现):
|
|
582
|
+
* - 只认 http(s)/ssh/scp 式网络地址与本机路径;file://、ext::、ftp://、git:// 一律拒绝;
|
|
583
|
+
* - 整串以 '-' 开头拒绝(git argv 位置参数以 '-' 开头会被当选项,可执行任意命令);
|
|
584
|
+
* - 网络地址拒绝环回/私网/链路本地主机(SSRF)—— 文案单独一条(sync.configPrivateHost),
|
|
585
|
+
* 不能复用「无法解析」:用户看到「解析不了」只会反复改写法,实际原因是地址指向内网;
|
|
586
|
+
* - 带 scheme 的地址一律拒绝 userinfo(username[:password]@)—— token 只许放凭据字段。
|
|
437
587
|
*/
|
|
438
588
|
export function validateRepoUrl(repoUrl: string, msg: MsgFunc = zhMsg): string | null {
|
|
439
589
|
if (typeof repoUrl !== 'string' || repoUrl.trim() === '') {
|
|
@@ -443,14 +593,34 @@ export function validateRepoUrl(repoUrl: string, msg: MsgFunc = zhMsg): string |
|
|
|
443
593
|
if (/\s/.test(url)) {
|
|
444
594
|
return msg('sync.configWhitespace');
|
|
445
595
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
596
|
+
// 回显一律脱敏:错误文案会进日志、进接口响应、进 issue 截图
|
|
597
|
+
const echo = sanitizeRepoUrlForMessage(url);
|
|
598
|
+
const shape = classifyRepoUrl(url);
|
|
599
|
+
if (shape === null) {
|
|
600
|
+
return msg('sync.configUnparseable', { url: echo });
|
|
601
|
+
}
|
|
602
|
+
if (shape.kind === 'local') return null;
|
|
603
|
+
if (isPrivateRepoHost(shape.host)) {
|
|
604
|
+
return msg('sync.configPrivateHost', { url: echo });
|
|
605
|
+
}
|
|
606
|
+
// userinfo 检查与 scheme 无关(RV1-F7):原实现只在 http/https 分支内查,于是
|
|
607
|
+
// `ssh://user:tok@host/r.git` 能过白名单,token 照样进 argv 与 sync-config.json。
|
|
608
|
+
// 口径(按「这一段是不是凭据」区分,而不是按 scheme 区分):
|
|
609
|
+
// - password 位:任何带 scheme 的地址都拒 —— 那是可粘贴的 secret;
|
|
610
|
+
// - http/https 的 username:Basic 认证的用户名与口令等同,一并拒(保持既有行为);
|
|
611
|
+
// - ssh 的 username:就是 ssh 登录名(`ssh://git@host`、`ssh://deploy@host` 是标准写法),
|
|
612
|
+
// ssh 认证只认密钥/agent,用户名不具凭据效力,因此放行 —— 拒掉会杀掉合法用法。
|
|
613
|
+
// scp 式(git@host:path)无 scheme、`new URL` 解析不了,本就不进这条判定。
|
|
614
|
+
let parsed: URL | null = null;
|
|
615
|
+
try {
|
|
616
|
+
parsed = new URL(url);
|
|
617
|
+
} catch {
|
|
618
|
+
parsed = null;
|
|
619
|
+
}
|
|
620
|
+
if (parsed !== null) {
|
|
621
|
+
const hasPassword = parsed.password !== '';
|
|
622
|
+
const hasBasicUser = parsed.username !== '' && (shape.kind === 'http' || shape.kind === 'https');
|
|
623
|
+
if (hasPassword || hasBasicUser) {
|
|
454
624
|
return msg('sync.configUserinfo');
|
|
455
625
|
}
|
|
456
626
|
}
|
|
@@ -2094,6 +2094,113 @@ test('P1-5: pull 按远端墓碑剔除已删除的会话(旧快照不复活)
|
|
|
2094
2094
|
}
|
|
2095
2095
|
});
|
|
2096
2096
|
|
|
2097
|
+
test('SYN-02: 远端墓碑被本机继承 → 下一次 push 仍带它(删除跨设备传播,旧快照不复活)', async () => {
|
|
2098
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-tombstone-inherit-'));
|
|
2099
|
+
try {
|
|
2100
|
+
const ctx = makeContext('win32', 'C:\\Users\\bob');
|
|
2101
|
+
seedSource(ctx);
|
|
2102
|
+
// 本机只有 b;a 是**另一台设备**删掉的 —— 本机从来没推过 a,靠 previousUnits 推断不出这条删除
|
|
2103
|
+
await ctx.fs.writeFile('sessions/--p--/b/session.jsonl.zstd', Buffer.from('b', 'utf8'));
|
|
2104
|
+
ctx.fs.setMtime('sessions/--p--/b/session.jsonl.zstd', 2000);
|
|
2105
|
+
const transport = new MemSyncTransport();
|
|
2106
|
+
const remote: SyncSnapshot = {
|
|
2107
|
+
id: 'remote-with-tombstones',
|
|
2108
|
+
createdAt: '2026-08-15T10:00:00.000Z',
|
|
2109
|
+
manifest: {
|
|
2110
|
+
schemaVersion: 1, dshVersion: '1.2.3', platform: 'linux',
|
|
2111
|
+
sectionIds: ['sessions'], containsSecrets: false,
|
|
2112
|
+
// 重复项:远端清单本身可能有重复,继承时必须去重保序
|
|
2113
|
+
deletedSessions: ['sessions:--p--/a', 'sessions:--p--/a', 'sessions:--q--/legacy'],
|
|
2114
|
+
},
|
|
2115
|
+
sections: { sessions: { version: 1, files: [] } },
|
|
2116
|
+
};
|
|
2117
|
+
transport.snapshots.set(remote.id, remote);
|
|
2118
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
2119
|
+
// 基线停在远端最新 → 本次 push 不被「远端已推进」拦下(继承墓碑与是否 force 无关)
|
|
2120
|
+
await saveSyncState(tmp, {
|
|
2121
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
2122
|
+
lastSyncAt: '2026-08-15T10:00:00.000Z', sections: {},
|
|
2123
|
+
lastSnapshotId: remote.id, ancestorId: '',
|
|
2124
|
+
sessionUnits: ['sessions:--p--/b'],
|
|
2125
|
+
deletedSessions: [],
|
|
2126
|
+
});
|
|
2127
|
+
const adapters = createAdapters({ namespaces: NS, includeSessions: true });
|
|
2128
|
+
const engine = new SyncEngine({
|
|
2129
|
+
ctx, transport, stateDir: tmp, adapters,
|
|
2130
|
+
importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
|
|
2131
|
+
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
2132
|
+
optInSections: ['sessions'],
|
|
2133
|
+
} as ConstructorParameters<typeof SyncEngine>[0]);
|
|
2134
|
+
|
|
2135
|
+
const report = await engine.push({ snapshotId: 'sync-inherit', sections: ['sessions'], sessions: {} });
|
|
2136
|
+
assert.equal(report.ok, true);
|
|
2137
|
+
assert.deepEqual(
|
|
2138
|
+
transport.snapshots.get('sync-inherit')!.manifest.deletedSessions,
|
|
2139
|
+
['sessions:--p--/a', 'sessions:--q--/legacy'],
|
|
2140
|
+
'远端墓碑必须被继承进新快照(否则第三台设备会复活已删会话)',
|
|
2141
|
+
);
|
|
2142
|
+
const state = await loadSyncState(tmp);
|
|
2143
|
+
assert.deepEqual(
|
|
2144
|
+
state.deletedSessions,
|
|
2145
|
+
['sessions:--p--/a', 'sessions:--q--/legacy'],
|
|
2146
|
+
'继承来的墓碑必须进 sync-state(下轮继续传播)',
|
|
2147
|
+
);
|
|
2148
|
+
assert.ok(report.warnings.length > 0, '继承墓碑必须可见(绝不静默): 无任何告警');
|
|
2149
|
+
} finally {
|
|
2150
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
test('SYN-02: 远端墓碑命中本机仍有实体的会话 → 继承时撤销墓碑(有实体 = 没删,绝不记成本机删除)', async () => {
|
|
2155
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-tombstone-revoke-'));
|
|
2156
|
+
try {
|
|
2157
|
+
const ctx = makeContext('win32', 'C:\\Users\\bob');
|
|
2158
|
+
seedSource(ctx);
|
|
2159
|
+
// a 在本机**存在**(从别处恢复/未删)→ 远端那条墓碑必须被撤销,不能继续传播
|
|
2160
|
+
await ctx.fs.writeFile('sessions/--p--/a/session.jsonl.zstd', Buffer.from('a', 'utf8'));
|
|
2161
|
+
ctx.fs.setMtime('sessions/--p--/a/session.jsonl.zstd', 1000);
|
|
2162
|
+
const transport = new MemSyncTransport();
|
|
2163
|
+
const remote: SyncSnapshot = {
|
|
2164
|
+
id: 'remote-stale-tombstone',
|
|
2165
|
+
createdAt: '2026-08-15T10:00:00.000Z',
|
|
2166
|
+
manifest: {
|
|
2167
|
+
schemaVersion: 1, dshVersion: '1.2.3', platform: 'linux',
|
|
2168
|
+
sectionIds: ['sessions'], containsSecrets: false,
|
|
2169
|
+
deletedSessions: ['sessions:--p--/a'],
|
|
2170
|
+
},
|
|
2171
|
+
sections: { sessions: { version: 1, files: [] } },
|
|
2172
|
+
};
|
|
2173
|
+
transport.snapshots.set(remote.id, remote);
|
|
2174
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
2175
|
+
await saveSyncState(tmp, {
|
|
2176
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
2177
|
+
lastSyncAt: '2026-08-15T10:00:00.000Z', sections: {},
|
|
2178
|
+
lastSnapshotId: remote.id, ancestorId: '',
|
|
2179
|
+
sessionUnits: ['sessions:--p--/a'],
|
|
2180
|
+
deletedSessions: [],
|
|
2181
|
+
});
|
|
2182
|
+
const adapters = createAdapters({ namespaces: NS, includeSessions: true });
|
|
2183
|
+
const engine = new SyncEngine({
|
|
2184
|
+
ctx, transport, stateDir: tmp, adapters,
|
|
2185
|
+
importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
|
|
2186
|
+
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
2187
|
+
optInSections: ['sessions'],
|
|
2188
|
+
} as ConstructorParameters<typeof SyncEngine>[0]);
|
|
2189
|
+
|
|
2190
|
+
const report = await engine.push({ snapshotId: 'sync-revoked', sections: ['sessions'], sessions: {} });
|
|
2191
|
+
assert.equal(report.ok, true);
|
|
2192
|
+
assert.deepEqual(
|
|
2193
|
+
transport.snapshots.get('sync-revoked')!.manifest.deletedSessions ?? [],
|
|
2194
|
+
[],
|
|
2195
|
+
'本机还有实体 → 墓碑撤销,绝不把「本机有数据」记成删除',
|
|
2196
|
+
);
|
|
2197
|
+
const state = await loadSyncState(tmp);
|
|
2198
|
+
assert.deepEqual(state.deletedSessions ?? [], [], 'sync-state 里也不该留下这条墓碑');
|
|
2199
|
+
} finally {
|
|
2200
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2097
2204
|
test('push: 快照 manifest 携带导出机 sourceHome(跨机重定基的数据源)', async () => {
|
|
2098
2205
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-sourcehome-'));
|
|
2099
2206
|
try {
|
|
@@ -2764,3 +2871,134 @@ test('applyMergeResolutions: 远端无快照 → 空报告(ok:true,不构造
|
|
|
2764
2871
|
}
|
|
2765
2872
|
});
|
|
2766
2873
|
|
|
2874
|
+
// ─── SYN-04b:GC 放弃(lastGcSkip)必须冒泡成 push 报告里的一条 warning ────────────────
|
|
2875
|
+
//
|
|
2876
|
+
// 背景:SYN-04 修掉了「引用 404 就当无引用继续删」的静默丢数据路径 —— 现在 webdav/s3 通道会
|
|
2877
|
+
// **放弃本轮 blob GC** 并记下放弃码(lastGcSkip:gc-skip-reference-missing / -unreadable /
|
|
2878
|
+
// -snapshot-unparsable)。但通道层没有 logger、delete() 又把 GC 异常吃掉,引擎侧此前零引用
|
|
2879
|
+
// ⇒ 用户看到的是「push 成功、裁剪成功」,实际这一轮 blob 回收根本没跑(孤儿文件会一直堆)。
|
|
2880
|
+
// 这里钉三件事:① 有放弃码 ⇒ warnings 里出现该码;② 同一轮多次 delete 只报一次;
|
|
2881
|
+
// ③ 能力探测:通道没有该字段 / 字段不是非空字符串 ⇒ 当没有,绝不凭空造告警。
|
|
2882
|
+
|
|
2883
|
+
/** 给内存通道挂一个「GC 放弃码」出口:形态与 webdav/s3 transport 的 lastGcSkip 一致(只读字段)。 */
|
|
2884
|
+
function attachGcSkip(transport: MemSyncTransport, initial: unknown): { code: unknown } {
|
|
2885
|
+
const box = { code: initial };
|
|
2886
|
+
Object.defineProperty(transport, 'lastGcSkip', { get: () => box.code, configurable: true });
|
|
2887
|
+
return box;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
test('SYN-04b/①:GC 放弃 ⇒ push 的 warnings 含放弃码,且同一轮只报一次', async () => {
|
|
2891
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-gcskip-'));
|
|
2892
|
+
try {
|
|
2893
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2894
|
+
seedSource(ctx);
|
|
2895
|
+
const transport = new MemSyncTransport();
|
|
2896
|
+
seedRemoteSnapshots(transport, 13); // 最旧 4 个会被裁剪 ⇒ 4 次 delete
|
|
2897
|
+
const box = attachGcSkip(transport, null);
|
|
2898
|
+
const origDelete = transport.delete.bind(transport);
|
|
2899
|
+
// 每次删除都触发一轮 GC,而这一轮的 GC 都放弃(真实场景:引用快照 404 / 读不出)
|
|
2900
|
+
transport.delete = async (id: string) => {
|
|
2901
|
+
box.code = 'gc-skip-reference-missing';
|
|
2902
|
+
return origDelete(id);
|
|
2903
|
+
};
|
|
2904
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2905
|
+
|
|
2906
|
+
const report = await engine.push({ snapshotId: 'sync-new' });
|
|
2907
|
+
assert.equal(report.ok, true, 'GC 放弃不影响 push 主流程');
|
|
2908
|
+
assert.equal(transport.calls.filter((c) => c === 'delete').length, 4, '四次裁剪删除都发生了');
|
|
2909
|
+
const hits = report.warnings.filter((w) => w.includes('gc-skip-reference-missing'));
|
|
2910
|
+
assert.equal(hits.length, 1, '同一轮只报一次(否则每删一个快照就刷一条同义告警):' + JSON.stringify(report.warnings));
|
|
2911
|
+
} finally {
|
|
2912
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
test('SYN-04b/②:通道没有 lastGcSkip 字段 ⇒ 不产生任何 GC 告警(能力探测,字段不存在当没有)', async () => {
|
|
2917
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-gcskip-absent-'));
|
|
2918
|
+
try {
|
|
2919
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2920
|
+
seedSource(ctx);
|
|
2921
|
+
const transport = new MemSyncTransport(); // 裸 Mem 通道:无该字段
|
|
2922
|
+
seedRemoteSnapshots(transport, 13);
|
|
2923
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2924
|
+
|
|
2925
|
+
const report = await engine.push({ snapshotId: 'sync-new' });
|
|
2926
|
+
assert.equal(report.ok, true);
|
|
2927
|
+
assert.equal(transport.calls.filter((c) => c === 'delete').length, 4);
|
|
2928
|
+
assert.ok(!report.warnings.some((w) => w.includes('gc-skip-')), '无放弃码就不得凭空告警:' + JSON.stringify(report.warnings));
|
|
2929
|
+
} finally {
|
|
2930
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
test('SYN-04b/③:lastGcSkip 不是非空字符串(null / 数字 / 空串)⇒ 一律当没有', async () => {
|
|
2935
|
+
for (const bogus of [null, 42, '']) {
|
|
2936
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-gcskip-bogus-'));
|
|
2937
|
+
try {
|
|
2938
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2939
|
+
seedSource(ctx);
|
|
2940
|
+
const transport = new MemSyncTransport();
|
|
2941
|
+
seedRemoteSnapshots(transport, 13);
|
|
2942
|
+
attachGcSkip(transport, bogus);
|
|
2943
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2944
|
+
|
|
2945
|
+
const report = await engine.push({ snapshotId: 'sync-new' });
|
|
2946
|
+
assert.equal(report.ok, true);
|
|
2947
|
+
assert.ok(
|
|
2948
|
+
!report.warnings.some((w) => w.includes('gc-skip-')),
|
|
2949
|
+
'非法放弃码(' + JSON.stringify(bogus) + ')不得变成告警:' + JSON.stringify(report.warnings),
|
|
2950
|
+
);
|
|
2951
|
+
} finally {
|
|
2952
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
// ─── F3:**手动**删快照撞上 GC 放弃时不得静默(RV1 复核:deleteSnapshot 此前 Promise<void>) ───
|
|
2958
|
+
//
|
|
2959
|
+
// 与上面 SYN-04b 的差别在触发路径:push 的 warnings 只覆盖「保留策略裁剪」那一轮;用户在产品库里
|
|
2960
|
+
// 显式点删除走的是 SyncEngine.deleteSnapshot → transport.delete → gcBlobStore,通道同样会放弃本轮
|
|
2961
|
+
// 回收,但旧实现把放弃码丢在原地 ⇒ 孤儿 blob 无人可见地累积。这里钉:放弃要有 warnings,没有就空。
|
|
2962
|
+
|
|
2963
|
+
test('F3/①:手动删快照 + 通道放弃本轮 GC ⇒ 返回体带该放弃码的 warning(快照仍按意图删除)', async () => {
|
|
2964
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-del-gcskip-'));
|
|
2965
|
+
try {
|
|
2966
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2967
|
+
seedSource(ctx);
|
|
2968
|
+
const transport = new MemSyncTransport();
|
|
2969
|
+
seedRemoteSnapshots(transport, 2);
|
|
2970
|
+
attachGcSkip(transport, 'gc-skip-reference-missing');
|
|
2971
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2972
|
+
|
|
2973
|
+
const result = await engine.deleteSnapshot('remote-01');
|
|
2974
|
+
|
|
2975
|
+
assert.deepEqual(transport.calls.filter((c) => c === 'delete'), ['delete'], '删除只发生一次');
|
|
2976
|
+
assert.ok(transport.metas.every((m) => m.id !== 'remote-01'), '快照本身已按用户意图删除');
|
|
2977
|
+
assert.equal(result.warnings.length, 1, '放弃本轮 GC 必须给出恰好一条 warning:' + JSON.stringify(result.warnings));
|
|
2978
|
+
assert.ok(result.warnings[0]!.includes('gc-skip-reference-missing'), 'warning 必须含放弃码明文(字典未补键也能 grep)');
|
|
2979
|
+
} finally {
|
|
2980
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2981
|
+
}
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
test('F3/②:手动删快照但没有放弃码(通道无该字段 / 非非空字符串)⇒ 零 warning', async () => {
|
|
2985
|
+
for (const spec of ['absent', null, 42, ''] as const) {
|
|
2986
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-del-gcskip-none-'));
|
|
2987
|
+
try {
|
|
2988
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2989
|
+
seedSource(ctx);
|
|
2990
|
+
const transport = new MemSyncTransport();
|
|
2991
|
+
seedRemoteSnapshots(transport, 1);
|
|
2992
|
+
if (spec !== 'absent') attachGcSkip(transport, spec);
|
|
2993
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2994
|
+
|
|
2995
|
+
const result = await engine.deleteSnapshot('remote-01');
|
|
2996
|
+
assert.deepEqual(result.warnings, [], '无放弃码(' + String(spec) + ')不得凭空造告警');
|
|
2997
|
+
assert.ok(transport.metas.every((m) => m.id !== 'remote-01'), '删除照常生效');
|
|
2998
|
+
} finally {
|
|
2999
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
|