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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { redact } from '../security/redaction.ts';
|
|
2
|
+
|
|
3
|
+
/** 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。 */
|
|
4
|
+
export const MERGE_PREVIEW_TEXT_LIMIT = 2048;
|
|
5
|
+
|
|
6
|
+
/** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
|
|
7
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。
|
|
11
|
+
*
|
|
12
|
+
* 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
|
|
13
|
+
* (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
|
|
14
|
+
*/
|
|
15
|
+
export function finalizePreviewText(text: string): string {
|
|
16
|
+
const masked = redact(text);
|
|
17
|
+
return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
|
|
21
|
+
export function previewTextOf(value: unknown): string {
|
|
22
|
+
if (value === undefined) return '';
|
|
23
|
+
if (typeof value === 'string') return finalizePreviewText(value);
|
|
24
|
+
if (value instanceof Uint8Array) {
|
|
25
|
+
try {
|
|
26
|
+
return finalizePreviewText(STRICT_UTF8.decode(value));
|
|
27
|
+
} catch {
|
|
28
|
+
// 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
|
|
29
|
+
return JSON.stringify({ binary: true, bytes: value.length });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return finalizePreviewText(JSON.stringify(value) ?? '');
|
|
34
|
+
} catch {
|
|
35
|
+
return finalizePreviewText(String(value));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* ② index 读操作抛错 → 显式失败(F2 语义,绝不回落成空索引);
|
|
12
12
|
* ③ index = 200 + [] → 索引权威地声明集合为空,保持既有语义(既有用例
|
|
13
13
|
* 「blob GC:删除最后一份快照后回收超保护窗的无人引用 blob」钉住该路径);
|
|
14
|
-
* ④ GC 遇到「索引有条目但快照文件读不出来(非 404)」→ 本轮放弃(读不出来 ≠
|
|
14
|
+
* ④ GC 遇到「索引有条目但快照文件读不出来(非 404)」→ 本轮放弃(读不出来 ≠ 没引用);
|
|
15
|
+
* ⑤ SYN-04(P1):与 WebDAV 同型 —— **404 也放弃本轮**(孤儿条目 / 假 404 拿不到内容就无法证明
|
|
16
|
+
* 「没人引用」),本轮 0 删除并记下放弃码(`lastGcSkip`,机器可判定)。
|
|
15
17
|
*/
|
|
16
18
|
import test from 'node:test';
|
|
17
19
|
import assert from 'node:assert/strict';
|
|
@@ -168,3 +170,26 @@ test('N1⑤:索引有条目但该快照文件读不出来(非 404)→ 本
|
|
|
168
170
|
assert.equal(remote.deletedBlobs.length, 0, '读不出 sync-ccc-333 就无法证明它没引用 blob → 本轮不得删任何 blob');
|
|
169
171
|
assert.equal(remote.objects.has(PREFIX + '/blobs/' + BLOB_HASH), true);
|
|
170
172
|
});
|
|
173
|
+
|
|
174
|
+
test('N1⑥(SYN-04):索引有条目但该快照文件真 404 → 本轮放弃 GC(404 也证明不了「没引用」)+ 记放弃码', async () => {
|
|
175
|
+
const remote = makeRemote({
|
|
176
|
+
index: JSON.stringify([meta('sync-aaa-111', '1'), meta('sync-ccc-333', '3')]),
|
|
177
|
+
snapshotFiles: { 'sync-aaa-111': snapOf('sync-aaa-111', PLAIN) },
|
|
178
|
+
});
|
|
179
|
+
const t = new S3Transport(makeOptions(remote));
|
|
180
|
+
await t.delete('sync-aaa-111');
|
|
181
|
+
assert.equal(remote.deletedBlobs.length, 0, '404 与「读不出来」同属「无法证明没有引用」→ 本轮 0 删除');
|
|
182
|
+
assert.equal(remote.objects.has(PREFIX + '/blobs/' + BLOB_HASH), true, '没有被证明无引用的 blob 必须保留');
|
|
183
|
+
assert.equal(t.lastGcSkip, 'gc-skip-reference-missing', '必须记下本轮放弃码(机器可判定,文案由上层按码翻译)');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('N1⑦(SYN-04):引用者 200 但内容坏掉 → 本轮放弃,放弃码 = snapshot-unparsable', async () => {
|
|
187
|
+
const remote = makeRemote({
|
|
188
|
+
index: JSON.stringify([meta('sync-aaa-111', '1'), meta('sync-ccc-333', '3')]),
|
|
189
|
+
snapshotFiles: { 'sync-aaa-111': snapOf('sync-aaa-111', PLAIN), 'sync-ccc-333': 'not-json' },
|
|
190
|
+
});
|
|
191
|
+
const t = new S3Transport(makeOptions(remote));
|
|
192
|
+
await t.delete('sync-aaa-111');
|
|
193
|
+
assert.equal(remote.deletedBlobs.length, 0, '解析不了 ⇒ 引用集不完整 ⇒ 本轮不得删任何 blob');
|
|
194
|
+
assert.equal(t.lastGcSkip, 'gc-skip-snapshot-unparsable');
|
|
195
|
+
});
|
|
@@ -26,6 +26,8 @@ import { requestOnce } from '../../utils/proxy.ts';
|
|
|
26
26
|
import type { RawResponse } from '../../utils/proxy.ts';
|
|
27
27
|
import { parseJsonSafe } from '../../utils/json.ts';
|
|
28
28
|
import type { MsgFunc } from '../../core/msg-types.ts';
|
|
29
|
+
import { GC_SKIP_REFERENCE_MISSING, GC_SKIP_REFERENCE_UNREADABLE, GC_SKIP_SNAPSHOT_UNPARSABLE } from '../gc-skip.ts';
|
|
30
|
+
import type { GcSkipCode } from '../gc-skip.ts';
|
|
29
31
|
import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.ts';
|
|
30
32
|
import {
|
|
31
33
|
BLOB_SECTIONS, gcBlobs, isBlobRefsSection, isFilesSectionLike, referencedBlobHashes,
|
|
@@ -213,6 +215,14 @@ export class S3Transport implements SyncTransport {
|
|
|
213
215
|
msg: MsgFunc;
|
|
214
216
|
};
|
|
215
217
|
|
|
218
|
+
/** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
|
|
219
|
+
private gcSkip: GcSkipCode | null = null;
|
|
220
|
+
|
|
221
|
+
/** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
|
|
222
|
+
get lastGcSkip(): GcSkipCode | null {
|
|
223
|
+
return this.gcSkip;
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
constructor(options: S3TransportOptions) {
|
|
217
227
|
// t79:宿主传进来的 msg(core 目录)**不包含** sync.s3.* 键,直接用会显示裸键名 —— 合并本地目录。
|
|
218
228
|
const msg = composeMsg(options.msg, s3Msg);
|
|
@@ -708,20 +718,29 @@ export class S3Transport implements SyncTransport {
|
|
|
708
718
|
}
|
|
709
719
|
|
|
710
720
|
/** blob 仓 GC:逐份读仍存在的快照收集引用,删除无人引用且超保护窗的 blob;
|
|
711
|
-
*
|
|
721
|
+
* 读不坏任何一份快照 → 正常回收,否则本轮整体放弃(宁可留垃圾,不可删在用的)。
|
|
722
|
+
* SYN-04(P1):**404 也放弃本轮** —— 条目在但文件 404 = 拿不到内容 = 无法证明「没人引用」,
|
|
723
|
+
* 按残集回收会删掉仍在用的 blob(与 WebDAV 同型,口径一致)。 */
|
|
712
724
|
private async gcBlobStore(secret: string, remaining: SyncSnapshotMeta[]): Promise<void> {
|
|
725
|
+
this.gcSkip = null;
|
|
713
726
|
const index = await this.readBlobIndex(secret);
|
|
714
727
|
if (Object.keys(index).length === 0) return;
|
|
715
728
|
const referenced = new Set<string>();
|
|
716
729
|
for (const meta of remaining) {
|
|
717
730
|
const sent = await this.send('GET', snapshotKey(this.target.prefix, meta.id), { secret });
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
731
|
+
if (sent.res.status === 404) {
|
|
732
|
+
this.gcSkip = GC_SKIP_REFERENCE_MISSING;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (!sent.res.ok) {
|
|
736
|
+
this.gcSkip = GC_SKIP_REFERENCE_UNREADABLE;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
721
739
|
let snap: SyncSnapshot;
|
|
722
740
|
try {
|
|
723
741
|
snap = deserializeSnapshot(await sent.res.text());
|
|
724
742
|
} catch {
|
|
743
|
+
this.gcSkip = GC_SKIP_SNAPSHOT_UNPARSABLE;
|
|
725
744
|
return;
|
|
726
745
|
}
|
|
727
746
|
const plain = snap.sections as Partial<Record<SectionId, unknown>>;
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* 本次不实现(保留信息可见,不代用户删数据)。
|
|
19
19
|
*/
|
|
20
20
|
import { sha256Hex } from '../utils/hashing.ts';
|
|
21
|
+
import { isEncryptedSections } from './transport.ts';
|
|
22
|
+
import type { SectionData, SectionId } from '../schema/types.ts';
|
|
23
|
+
import type { SyncSnapshotMeta, SyncTransport } from './transport.ts';
|
|
21
24
|
|
|
22
25
|
/** 会话单元 id 前缀(与 SessionsAdapter.unitIdOf 同口径:`sessions:<projectKey>/<sessionId>`)。 */
|
|
23
26
|
export const SESSION_UNIT_PREFIX = 'sessions:';
|
|
@@ -121,3 +124,90 @@ export function sessionUnitIdsOfSection(section: unknown): string[] {
|
|
|
121
124
|
export function unitSetFingerprint(units: readonly string[]): string {
|
|
122
125
|
return sha256Hex([...units].sort().join('\n'));
|
|
123
126
|
}
|
|
127
|
+
|
|
128
|
+
/* ---------------- SYN-02:墓碑跨设备继承(远端 → 本机 → 下一份快照) ---------------- */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 一次远端 list() 的读取结果。
|
|
132
|
+
*
|
|
133
|
+
* `metas` 为空**且**无 `reason` 才代表「远端确实为空」;有 reason = 读不到(按「看不到远端」
|
|
134
|
+
* 处理,调用方告警后继续,绝不把它当成「远端没东西」而误判)。
|
|
135
|
+
*/
|
|
136
|
+
export interface RemoteSnapshotIndex {
|
|
137
|
+
metas: SyncSnapshotMeta[];
|
|
138
|
+
reason?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 读一次远端快照索引(只读、零写入)。push 的「是否被推进」判定与墓碑继承共用这一份。 */
|
|
142
|
+
export async function listRemoteSnapshotIndex(transport: Pick<SyncTransport, 'list'>): Promise<RemoteSnapshotIndex> {
|
|
143
|
+
try {
|
|
144
|
+
return { metas: await transport.list() };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { metas: [], reason: error instanceof Error ? error.message : String(error) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 远端最新快照携带的会话删除墓碑(去重、去空、保序)。
|
|
152
|
+
*
|
|
153
|
+
* 只取**最新一份**:manifest.deletedSessions 是累积清单(见文件头),旧快照的墓碑必然被后续
|
|
154
|
+
* 快照包含;把整段历史并起来只会让「已经在别处撤销的墓碑」复活。
|
|
155
|
+
* 远端为空 / 老快照缺字段 → 空数组(行为与改造前逐字一致:不继承任何墓碑)。
|
|
156
|
+
*/
|
|
157
|
+
export function remoteTombstonesOf(metas: readonly SyncSnapshotMeta[]): string[] {
|
|
158
|
+
const latest = metas.length === 0 ? undefined : metas[metas.length - 1];
|
|
159
|
+
const list = latest?.manifest.deletedSessions ?? [];
|
|
160
|
+
return [...new Set(list.filter((id) => typeof id === 'string' && id !== ''))];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 一次 push 的会话簿记结果(manifest / baseline / 告警共用这一份)。 */
|
|
164
|
+
export interface SessionBookkeepingResult {
|
|
165
|
+
/** false = 本次没有可用的会话枚举(没推 sessions / 适配器不支持 / 枚举失败或为空) */
|
|
166
|
+
tracked: boolean;
|
|
167
|
+
sessionUnits: string[];
|
|
168
|
+
tombstones: string[];
|
|
169
|
+
/** 本次**新**发现的删除(之前推过、这次本机没了) */
|
|
170
|
+
deletedNow: string[];
|
|
171
|
+
/** 从远端继承来的墓碑条数(本机此前没有、且本机也没有实体) */
|
|
172
|
+
inherited: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 墓碑集合的唯一计算点(本机删除 + 远端继承合进同一条累积链)。
|
|
177
|
+
*
|
|
178
|
+
* 去重、FIFO 上限、以及「本机还有实体 ⇒ 撤销墓碑」这条不变量对继承来的墓碑同样适用:
|
|
179
|
+
* 另一台设备删了、本机仍有 → 用户在本机显然没删,绝不把它记成本机删除。
|
|
180
|
+
*/
|
|
181
|
+
export function planSessionBookkeeping(args: {
|
|
182
|
+
previousUnits: readonly string[];
|
|
183
|
+
previousTombstones: readonly string[];
|
|
184
|
+
localUnits: readonly string[];
|
|
185
|
+
exportedSessions: SectionData | undefined;
|
|
186
|
+
remoteTombstones: readonly string[];
|
|
187
|
+
}): Omit<SessionBookkeepingResult, 'tracked'> {
|
|
188
|
+
const known = args.previousTombstones;
|
|
189
|
+
const knownSet = new Set(known);
|
|
190
|
+
const localSet = new Set(args.localUnits);
|
|
191
|
+
const inherited = args.remoteTombstones.filter((id) => !knownSet.has(id) && !localSet.has(id)).length;
|
|
192
|
+
const { tombstones, deletedNow } = nextSessionTombstones({
|
|
193
|
+
previousUnits: [...args.previousUnits],
|
|
194
|
+
localUnits: [...args.localUnits],
|
|
195
|
+
previousTombstones: [...known, ...args.remoteTombstones],
|
|
196
|
+
});
|
|
197
|
+
return { sessionUnits: sessionUnitIdsOfSection(args.exportedSessions), tombstones, deletedNow, inherited };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 按墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。只动载荷,不动本机数据。 */
|
|
201
|
+
export function stripTombstonedSessionsInPlace<P extends {
|
|
202
|
+
manifest: { deletedSessions?: readonly string[] };
|
|
203
|
+
sections: unknown;
|
|
204
|
+
}>(snapshot: P): string[] {
|
|
205
|
+
const tombstones = snapshot.manifest.deletedSessions ?? [];
|
|
206
|
+
if (tombstones.length === 0 || isEncryptedSections(snapshot.sections)) return [];
|
|
207
|
+
const plain = snapshot.sections as Partial<Record<SectionId, SectionData>>;
|
|
208
|
+
const stripped = stripTombstonedUnits(plain['sessions'], tombstones);
|
|
209
|
+
if (stripped.removed.length === 0) return [];
|
|
210
|
+
plain['sessions'] = stripped.section as SectionData;
|
|
211
|
+
return stripped.removed;
|
|
212
|
+
}
|
|
213
|
+
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
readSyncConfig, readFullSyncConfig, readSyncConfigFor, writeSyncConfig, isGitConfig, isWebDavConfig,
|
|
25
25
|
isS3Config, isGistConfig, listConfiguredChannels, readActiveS3Provider,
|
|
26
26
|
SYNC_CONFIG_FILE, SYNC_CONFIG_SCHEMA_VERSION, SYNC_CONFIG_SUPPORTED_VERSIONS, CLOUD_SYNC_CONFIG_FILE,
|
|
27
|
-
validateWebDavUrl, type SyncConfig,
|
|
27
|
+
validateWebDavUrl, validateRepoUrl, type SyncConfig, type SyncConfigIssue,
|
|
28
28
|
SYNC_CHANNELS, channelOf, channelMap, isSyncTransportType, parseSyncChannel, clearSyncChannel,
|
|
29
29
|
S3_COMPAT_PROVIDERS, CLOUD_SYNC_PROVIDERS, cloudSecretRef,
|
|
30
30
|
} from './sync-config.ts';
|
|
@@ -739,3 +739,172 @@ test('t88 跨侧常量守卫:S3 兼容商清单与云端点密钥槽位引用
|
|
|
739
739
|
'client 侧必须 re-export 共享派生函数(不得自己再实现一遍)',
|
|
740
740
|
);
|
|
741
741
|
});
|
|
742
|
+
/* ------------------------------------------------------------------------------------------------
|
|
743
|
+
* SEC-02 / SEC-03 / SEC-12:repoUrl 白名单(审计 §安全 P0 链)
|
|
744
|
+
*
|
|
745
|
+
* 事实:repoUrl 直接进 git 子进程 argv,且 http(s) 通道会把凭据注入到它的 host 上。原实现只拒
|
|
746
|
+
* 空白与 userinfo,于是「file:// 任意本机仓」「ext::sh -c …」「-c core.pager=…」、环回/私网地址
|
|
747
|
+
* 全都能当同步目标 —— 一旦写进本机配置,git 就会按包方指定的地址执行/送凭据。
|
|
748
|
+
* 白名单口径:只认 http/https/ssh/scp-like 远端;无 host、私网/环回/链路本地、非网络方案、
|
|
749
|
+
* 整串以 '-' 开头一律拒绝。本机路径保留(git 通道的老用法,见 GitTransportOptions.repoUrl 注释)。
|
|
750
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
751
|
+
|
|
752
|
+
test('validateRepoUrl(SEC-02/03/12):白名单 —— http(s)/ssh/scp-like 远端通过,危险方案与私网地址一律拒绝', () => {
|
|
753
|
+
// 通过:真实用法的四种形状(https / http / ssh:// / scp-like)+ 本机路径
|
|
754
|
+
for (const ok of [
|
|
755
|
+
'https://github.com/u/r.git',
|
|
756
|
+
'http://git.example.com/u/r.git',
|
|
757
|
+
'ssh://git@git.example.com:2222/u/r.git',
|
|
758
|
+
'git@github.com:u/r.git',
|
|
759
|
+
'git@git.example.com:team/r.git',
|
|
760
|
+
'/home/bob/repos/config.git',
|
|
761
|
+
'../bare',
|
|
762
|
+
'D:/repos/bare',
|
|
763
|
+
'https://github.com/u/r-1.git',
|
|
764
|
+
// 私网边界外(172.16/12 之外的 172.x 与 11.x 不是保留段)
|
|
765
|
+
'https://172.32.0.1/r.git',
|
|
766
|
+
'https://11.0.0.1/r.git',
|
|
767
|
+
]) {
|
|
768
|
+
assert.equal(validateRepoUrl(ok), null, '应通过: ' + ok);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// 拒绝:方案白名单(SEC-03)+ 无 host + 私网/环回/链路本地(SEC-02)+ 以 '-' 开头(SEC-12)
|
|
772
|
+
for (const bad of [
|
|
773
|
+
'file:///c:/repos/x.git',
|
|
774
|
+
'file:///etc/passwd',
|
|
775
|
+
'ext::sh -c touch% /tmp/pwned',
|
|
776
|
+
'ftp://git.example.com/r.git',
|
|
777
|
+
'git://git.example.com/r.git',
|
|
778
|
+
'https://',
|
|
779
|
+
'https:///r.git',
|
|
780
|
+
'https://127.0.0.1:8443/r.git',
|
|
781
|
+
'http://localhost:3000/r.git',
|
|
782
|
+
'https://[::1]/r.git',
|
|
783
|
+
'ssh://git@127.0.0.1/r.git',
|
|
784
|
+
'git@localhost:r.git',
|
|
785
|
+
'https://10.0.0.5/r.git',
|
|
786
|
+
'https://192.168.1.10/r.git',
|
|
787
|
+
'https://172.16.0.9/r.git',
|
|
788
|
+
'https://172.31.255.1/r.git',
|
|
789
|
+
'https://169.254.169.254/latest/meta-data/',
|
|
790
|
+
'https://[fe80::1]/r.git',
|
|
791
|
+
'-c core.pager=touch /tmp/pwned',
|
|
792
|
+
'--upload-pack=touch /tmp/pwned',
|
|
793
|
+
'https://user:ghp_token@github.com/u/r.git',
|
|
794
|
+
'https://github.com/u/r .git',
|
|
795
|
+
]) {
|
|
796
|
+
const err = validateRepoUrl(bad);
|
|
797
|
+
assert.equal(typeof err, 'string', '必须拒绝: ' + bad);
|
|
798
|
+
assert.ok(err !== null && err.length > 0, '拒绝必须带可展示原因: ' + bad);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test('readSyncConfig(SEC-01 读侧):已存 repoUrl 走同一校验;不合法 → 不返回该配置并给明确 issue', async () => {
|
|
803
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-repo-'));
|
|
804
|
+
try {
|
|
805
|
+
// 合法:照常读出,无 issue
|
|
806
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
|
|
807
|
+
schemaVersion: 3, transport: 'git',
|
|
808
|
+
git: { repoUrl: 'https://github.com/u/r.git' }, webdav: {},
|
|
809
|
+
}, null, 2), 'utf8');
|
|
810
|
+
const issues: SyncConfigIssue[] = [];
|
|
811
|
+
const ok = await readSyncConfig(dir, issues);
|
|
812
|
+
assert.equal(issues.length, 0, '合法 repoUrl 不该产生 issue');
|
|
813
|
+
assert.ok(ok !== null && isGitConfig(ok));
|
|
814
|
+
|
|
815
|
+
// 不合法:包方写进来的 file:// 目标 —— 读侧必须当成「未配置」,且给出明确原因
|
|
816
|
+
for (const badUrl of [
|
|
817
|
+
'file:///c:/repos/x.git',
|
|
818
|
+
'ext::sh -c touch% /tmp/pwned',
|
|
819
|
+
'https://169.254.169.254/latest/meta-data/',
|
|
820
|
+
'https://user:ghp_x@github.com/u/r.git',
|
|
821
|
+
]) {
|
|
822
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
|
|
823
|
+
schemaVersion: 3, transport: 'git', git: { repoUrl: badUrl }, webdav: {},
|
|
824
|
+
}, null, 2), 'utf8');
|
|
825
|
+
const local: SyncConfigIssue[] = [];
|
|
826
|
+
const cfg = await readSyncConfig(dir, local);
|
|
827
|
+
assert.equal(cfg, null, '不合法的 repoUrl 不得进入后续凭据注入: ' + badUrl);
|
|
828
|
+
assert.equal(local.length, 1, '必须显式记录被拒的通道,不得静默: ' + badUrl);
|
|
829
|
+
assert.equal(local[0]?.code, 'repo-url-invalid');
|
|
830
|
+
assert.equal(local[0]?.channel, 'git');
|
|
831
|
+
assert.ok((local[0]?.message ?? '').length > 0, '必须带可展示原因');
|
|
832
|
+
|
|
833
|
+
// 省略 issue 收集参数时行为不变(老调用方仍拿 null = 未配置)
|
|
834
|
+
assert.equal(await readSyncConfig(dir), null);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// v1 旧文件(扁平 repoUrl)走同一校验
|
|
838
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({ schemaVersion: 1, repoUrl: 'file:///etc' }), 'utf8');
|
|
839
|
+
const legacyIssues: SyncConfigIssue[] = [];
|
|
840
|
+
assert.equal(await readSyncConfig(dir, legacyIssues), null, 'v1 旧文件的 repoUrl 也要校验');
|
|
841
|
+
assert.equal(legacyIssues.length, 1);
|
|
842
|
+
|
|
843
|
+
// 本机路径仍是 git 通道的合法目标(产品既有能力,不因白名单被砍)
|
|
844
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
|
|
845
|
+
schemaVersion: 3, transport: 'git', git: { repoUrl: '/home/bob/repos/config.git' }, webdav: {},
|
|
846
|
+
}, null, 2), 'utf8');
|
|
847
|
+
const localIssues: SyncConfigIssue[] = [];
|
|
848
|
+
const localCfg = await readSyncConfig(dir, localIssues);
|
|
849
|
+
assert.equal(localIssues.length, 0);
|
|
850
|
+
assert.ok(localCfg !== null && isGitConfig(localCfg) && localCfg.git.repoUrl === '/home/bob/repos/config.git');
|
|
851
|
+
} finally {
|
|
852
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
/* ------------------------------------------------------------------------------------------------
|
|
857
|
+
* RV1-F6 / F7:私网拒绝的文案口径 + userinfo 覆盖 ssh(复核在 SEC-02/12 里抓到的两处边角)
|
|
858
|
+
*
|
|
859
|
+
* F6:环回/私网复用「无法解析仓库地址」= 给了用户错的方向(他会反复改写法)。真实判据是
|
|
860
|
+
* 「这个地址指向本机/内网,不作为同步目标」,因此单独一条 sync.configPrivateHost。
|
|
861
|
+
* F7:userinfo 检查原来只在 http/https 分支内,`ssh://user:tok@host/r.git` 能过白名单,
|
|
862
|
+
* token 会跟着 repoUrl 进 argv、进 sync-config.json。口径改成「password 位任何 scheme 都拒」。
|
|
863
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
864
|
+
|
|
865
|
+
test('validateRepoUrl(RV1-F6):私网/环回用专属文案,且回显地址已脱敏(不含 userinfo)', () => {
|
|
866
|
+
const cases: Array<[string, string]> = [
|
|
867
|
+
['https://127.0.0.1:8443/r.git', '127.0.0.1:8443'],
|
|
868
|
+
['http://localhost:3000/r.git', 'localhost:3000'],
|
|
869
|
+
['https://192.168.1.10/r.git', '192.168.1.10'],
|
|
870
|
+
['https://169.254.169.254/latest/meta-data/', '169.254.169.254'],
|
|
871
|
+
['ssh://git@10.0.0.5/r.git', '10.0.0.5'],
|
|
872
|
+
];
|
|
873
|
+
for (const [url, host] of cases) {
|
|
874
|
+
const err = validateRepoUrl(url);
|
|
875
|
+
assert.ok(err !== null, '私网地址必须拒绝: ' + url);
|
|
876
|
+
assert.ok(err.includes('本机或内网'), '原因必须是「指向本机/内网」,不能复用解析失败: ' + err);
|
|
877
|
+
assert.ok(!err.includes('无法解析'), '不得再用「无法解析仓库地址」描述私网: ' + err);
|
|
878
|
+
assert.ok(err.includes(host), '要能认出被拒的是哪个地址: ' + err);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// 回显脱敏:私网 + userinfo 同时出现时,文案里只能有 host,不能有 user/token
|
|
882
|
+
const withToken = validateRepoUrl('https://user:ghp_secretvalue@192.168.1.10/r.git');
|
|
883
|
+
assert.ok(withToken !== null);
|
|
884
|
+
assert.ok(withToken.includes('192.168.1.10'), '地址主体保留: ' + withToken);
|
|
885
|
+
assert.ok(!withToken.includes('ghp_secretvalue') && !withToken.includes('user:'), '文案不得回显凭据: ' + withToken);
|
|
886
|
+
|
|
887
|
+
// 私网判定先于 userinfo:带 userinfo 的私网地址仍报「内网」,而不是「别带凭据」
|
|
888
|
+
assert.ok(withToken.includes('本机或内网'), '私网判定优先: ' + withToken);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test('validateRepoUrl(RV1-F7):带 scheme 的 userinfo —— ssh 的 token 也被拒,ssh 登录名仍放行', () => {
|
|
892
|
+
// ssh 里 password 位 = 可粘贴的 token → 拒
|
|
893
|
+
for (const bad of ['ssh://user:tok@example.com/r.git', 'ssh://git:ghp_abc@example.com:22/u/r.git', 'ssh://:tok@example.com/r.git']) {
|
|
894
|
+
assert.equal(validateRepoUrl(bad), validateRepoUrl('https://user:tok@example.com/r.git'), 'ssh userinfo 与 http 同口径: ' + bad);
|
|
895
|
+
assert.ok((validateRepoUrl(bad) ?? '').includes('用户名/密码'), '必须是 userinfo 文案: ' + bad);
|
|
896
|
+
}
|
|
897
|
+
// ssh 的 username 是登录名(标准写法)→ 放行;否则会杀掉合法用法
|
|
898
|
+
for (const ok of ['ssh://git@git.example.com:2222/u/r.git', 'ssh://deploy@git.example.com/u/r.git', 'git@github.com:u/r.git']) {
|
|
899
|
+
assert.equal(validateRepoUrl(ok), null, 'ssh 登录名不是凭据,必须放行: ' + ok);
|
|
900
|
+
}
|
|
901
|
+
// http(s) 的 username 与口令等同(Basic 认证)→ 仍拒
|
|
902
|
+
for (const bad of ['https://user@github.com/u/r.git', 'http://user@git.example.com/u/r.git']) {
|
|
903
|
+
assert.ok((validateRepoUrl(bad) ?? '').includes('用户名/密码'), 'http(s) 的 username 仍拒: ' + bad);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// 解析不了的半截地址也必须脱敏回显(`https://user:tok@` 前缀就是凭据)
|
|
907
|
+
const half = validateRepoUrl('https://user:ghp_halfsecret@');
|
|
908
|
+
assert.ok(half !== null);
|
|
909
|
+
assert.ok(!half.includes('ghp_halfsecret'), '无法解析的地址同样不得回显凭据: ' + half);
|
|
910
|
+
});
|