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
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
* → engine.applyMergePlan(autoApply) 写入本地
|
|
33
33
|
* - Phase C: 完整双向 → engine.push() 上传。两处策略:
|
|
34
34
|
* ① 无冲突且推送被远端推进挡下 → skipped/partial + skipReason='conflict'(等人工);
|
|
35
|
-
* ② conflictPolicy='local'
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* ② conflictPolicy='local'(**只有用户显式选过才生效**;缺省 ask)且本轮有冲突 → **不写本地**,
|
|
36
|
+
* 改 engine.push({force:true}) 把本机版本推成新的远端最新(被覆盖的远端旧版本仍在远端历史快照里
|
|
37
|
+
* 可回滚),状态记 partial + skipReason='conflict-kept-local'(绝不谎报 success);
|
|
38
|
+
* SYN-05:首轮 / 远端有新快照时即使选了 local 也降级 ask 并记 forced=true(见
|
|
39
|
+
* resolveConflictPushPolicy)——这两个时刻覆盖远端等于替用户丢数据。
|
|
38
40
|
* - 收尾:写该通道 autosync-config(lastRunAt, lastRunStatus, consecutiveFailures,
|
|
39
41
|
* lastRunHistoryId, lastRunMessage/跳过原因 —— 每轮覆盖上一轮,绝不残留旧错误)
|
|
40
42
|
*
|
|
@@ -61,10 +63,11 @@ import {
|
|
|
61
63
|
} from './autosync-config.ts';
|
|
62
64
|
import type { AutosyncConfig, AutosyncInterval } from './autosync-config.ts';
|
|
63
65
|
import { readSyncConfigFor, isGitConfig, isWebDavConfig, isS3Config, isGistConfig, SYNC_CHANNELS } from './sync-config.ts';
|
|
64
|
-
import type { SyncConfig, SyncTransportType } from './sync-config.ts';
|
|
66
|
+
import type { SyncConfig, SyncConfigIssue, SyncTransportType } from './sync-config.ts';
|
|
65
67
|
import { readSyncHistory, appendAutosyncEntry, markAutosyncNotified } from './sync-history.ts';
|
|
66
68
|
import { classifyMergePlan } from './risk.ts';
|
|
67
69
|
import type { AutosyncHistoryEntry } from './sync-history.ts';
|
|
70
|
+
import type { AutosyncConflictPolicy } from './autosync-config.ts';
|
|
68
71
|
import type { MergePlan } from './merge.ts';
|
|
69
72
|
import type { SyncApplyPlan } from './risk.ts';
|
|
70
73
|
import { SyncWatcher } from './sync-watcher.ts';
|
|
@@ -111,6 +114,42 @@ export interface AutosyncRunResult {
|
|
|
111
114
|
error?: string;
|
|
112
115
|
historyId: string;
|
|
113
116
|
consecutiveFailures: number;
|
|
117
|
+
/**
|
|
118
|
+
* SYN-05:本轮 ask 是**被强制**的(首轮或远端有新快照),不是用户选的 ask。
|
|
119
|
+
* 界面据此说清「这次没让你选」,避免用户以为自己的 local 设置失效了。
|
|
120
|
+
*/
|
|
121
|
+
forced?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* SEC-03:本轮 skipReason='unconfigured' 时,已落盘配置为何被读侧丢弃(地址被白名单拒绝)。
|
|
124
|
+
* 缺省 undefined = 真的没配。有值就必须让用户看见 —— 否则「保存过却被拒」和
|
|
125
|
+
* 「从来没配」在界面上长得一模一样,用户会反复重填同一个被拒地址。
|
|
126
|
+
*/
|
|
127
|
+
configIssue?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* SYN-05:本轮冲突(下行冲突)该不该 force push —— 唯一判定点。
|
|
132
|
+
*
|
|
133
|
+
* 规则(缺省 ask 之上再加两道高危闸):
|
|
134
|
+
* - 用户没选过策略 → 用缺省(ask);
|
|
135
|
+
* - **首轮**(从未跑过)或**远端有新快照**:即使显式选了 'local' 也强制降级 ask(forcedAsk)——
|
|
136
|
+
* 这两个时刻正是「远端最新改动只在历史快照里」的事故现场,静默覆盖等于替用户丢数据;
|
|
137
|
+
* 降级记 forced,界面才能说清「这次没按你的 local 走」;
|
|
138
|
+
* - 只有「显式 local + 非高危时刻 + 真有冲突」才 force。
|
|
139
|
+
*
|
|
140
|
+
* 纯函数(无状态、无 I/O):调度层与单测共用同一份判定,避免两处各写一遍阈值。
|
|
141
|
+
*/
|
|
142
|
+
export function resolveConflictPushPolicy(args: {
|
|
143
|
+
configuredPolicy?: AutosyncConflictPolicy;
|
|
144
|
+
firstRun: boolean;
|
|
145
|
+
remoteNew: boolean;
|
|
146
|
+
conflicted: number;
|
|
147
|
+
}): { policy: AutosyncConflictPolicy; force: boolean; forcedAsk: boolean } {
|
|
148
|
+
const configured = args.configuredPolicy ?? DEFAULT_AUTOSYNC_CONFLICT_POLICY;
|
|
149
|
+
// forcedAsk 只描述「用户选了 local,但本轮被降级」——缺省 ask 不算被强制(那不是用户的决定被推翻)。
|
|
150
|
+
const forcedAsk = configured === 'local' && (args.firstRun || args.remoteNew);
|
|
151
|
+
const policy: AutosyncConflictPolicy = forcedAsk ? 'ask' : configured;
|
|
152
|
+
return { policy, force: policy === 'local' && args.conflicted > 0, forcedAsk };
|
|
114
153
|
}
|
|
115
154
|
|
|
116
155
|
/**
|
|
@@ -147,7 +186,11 @@ export interface AutoSyncSchedulerOptions {
|
|
|
147
186
|
readConfig?: (channel: SyncTransportType) => Promise<AutosyncConfig>;
|
|
148
187
|
writeConfig?: (channel: SyncTransportType, cfg: AutosyncConfig) => Promise<void>;
|
|
149
188
|
/** 注入 sync-config 读取(按通道) */
|
|
150
|
-
|
|
189
|
+
/**
|
|
190
|
+
* SEC-03:可选第二参收集「读侧被丢弃的配置成因」。既有注入实现(单参闭包)依然合法 ——
|
|
191
|
+
* 少一个参数不是类型错误,缺省接线才负责真正回填。
|
|
192
|
+
*/
|
|
193
|
+
readSyncConfigFn?: (channel: SyncTransportType, issues?: SyncConfigIssue[]) => Promise<SyncConfig | null>;
|
|
151
194
|
/** 注入 sync-history 读写 */
|
|
152
195
|
readHistoryFn?: () => Promise<Awaited<ReturnType<typeof readSyncHistory>>>;
|
|
153
196
|
appendHistoryFn?: (entry: AutosyncHistoryEntry) => Promise<void>;
|
|
@@ -204,7 +247,7 @@ export class AutoSyncScheduler {
|
|
|
204
247
|
private readonly now: () => Date;
|
|
205
248
|
private readonly readConfig: (channel: SyncTransportType) => Promise<AutosyncConfig>;
|
|
206
249
|
private readonly writeConfig: (channel: SyncTransportType, cfg: AutosyncConfig) => Promise<void>;
|
|
207
|
-
private readonly readSyncConfigFn: (channel: SyncTransportType) => Promise<SyncConfig | null>;
|
|
250
|
+
private readonly readSyncConfigFn: (channel: SyncTransportType, issues?: SyncConfigIssue[]) => Promise<SyncConfig | null>;
|
|
208
251
|
private readonly readHistoryFn: () => Promise<Awaited<ReturnType<typeof readSyncHistory>>>;
|
|
209
252
|
private readonly appendHistoryFn: (entry: AutosyncHistoryEntry) => Promise<void>;
|
|
210
253
|
private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
@@ -239,6 +282,8 @@ export class AutoSyncScheduler {
|
|
|
239
282
|
private readonly profile: string | undefined;
|
|
240
283
|
private readonly makeWatcher: ((opts: WatcherFactoryOptions) => SyncWatcher) | undefined;
|
|
241
284
|
private readonly minTriggerGapMs: number;
|
|
285
|
+
/** SEC-03:已 warn 过的「地址被拒」成因(键 = 通道 + 成因文本),进程内去重。 */
|
|
286
|
+
private readonly reportedConfigIssues = new Set<string>();
|
|
242
287
|
private stopped = false;
|
|
243
288
|
private running = false;
|
|
244
289
|
|
|
@@ -250,7 +295,7 @@ export class AutoSyncScheduler {
|
|
|
250
295
|
this.now = opts.now ?? (() => new Date());
|
|
251
296
|
this.readConfig = opts.readConfig ?? ((channel) => readAutosyncConfig(this.syncDir, channel));
|
|
252
297
|
this.writeConfig = opts.writeConfig ?? ((channel, cfg) => writeAutosyncConfig(this.syncDir, channel, cfg));
|
|
253
|
-
this.readSyncConfigFn = opts.readSyncConfigFn ?? ((channel) => readSyncConfigFor(this.syncDir, channel));
|
|
298
|
+
this.readSyncConfigFn = opts.readSyncConfigFn ?? ((channel, issues) => readSyncConfigFor(this.syncDir, channel, issues));
|
|
254
299
|
this.readHistoryFn = opts.readHistoryFn ?? (() => readSyncHistory(this.syncDir));
|
|
255
300
|
this.appendHistoryFn = opts.appendHistoryFn ?? ((entry) => appendAutosyncEntry(this.syncDir, entry));
|
|
256
301
|
this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
@@ -266,11 +311,32 @@ export class AutoSyncScheduler {
|
|
|
266
311
|
this.minTriggerGapMs = opts.minTriggerGapMs ?? 3000;
|
|
267
312
|
}
|
|
268
313
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
314
|
+
/**
|
|
315
|
+
* 追加一条自动同步历史(自动带上触发通道;0x 安全:绝不覆盖调用方显式 transport)。
|
|
316
|
+
*
|
|
317
|
+
* SYN-05:额外允许 forced(本轮 ask 是被强制的)。该字段不在 sync-history.ts 的
|
|
318
|
+
* AutosyncHistoryEntry 里(那是另一个模块的公开契约,本轮不扩),但读写两侧都按普通对象
|
|
319
|
+
* 原样透传(readSyncHistory 逐条 cast),所以运行时可稳定往返;界面按「字段有无」渲染。
|
|
320
|
+
*/
|
|
321
|
+
private async appendHistory(
|
|
322
|
+
channel: SyncTransportType,
|
|
323
|
+
entry: AutosyncHistoryEntry & { forced?: boolean; configIssue?: string },
|
|
324
|
+
): Promise<void> {
|
|
271
325
|
await this.appendHistoryFn({ ...entry, transport: channel });
|
|
272
326
|
}
|
|
273
327
|
|
|
328
|
+
/**
|
|
329
|
+
* SEC-03:「地址被拒」是持久状态,而自动同步每 interval(缺省 60s)就转一圈 ——
|
|
330
|
+
* 每轮打一条日志等于把日志刷成噪音。同一 (通道 + 成因) 只 warn 一次。
|
|
331
|
+
* 用户改了配置后成因文本会变(message 带真实地址/原因),自然再报一次。
|
|
332
|
+
*/
|
|
333
|
+
private reportConfigIssue(channel: SyncTransportType, detail: string): void {
|
|
334
|
+
const key = channel + '/' + detail;
|
|
335
|
+
if (this.reportedConfigIssues.has(key)) return;
|
|
336
|
+
this.reportedConfigIssues.add(key);
|
|
337
|
+
this.host.log.warn(`自动同步已跳过(${channel}):同步通道配置被安全校验拒绝,请重新填写地址(${detail})`);
|
|
338
|
+
}
|
|
339
|
+
|
|
274
340
|
/** 启动:读两个通道配置 → enabled 通道各自排定时器 → 对每个 enabled 通道执行一次启动触发。 */
|
|
275
341
|
start(): void {
|
|
276
342
|
if (this.stopped) return;
|
|
@@ -559,11 +625,17 @@ export class AutoSyncScheduler {
|
|
|
559
625
|
|
|
560
626
|
try {
|
|
561
627
|
// 读该通道 sync-config:按通道判定「已配置」——git 看 git.repoUrl、webdav 看 webdav.url。
|
|
562
|
-
|
|
628
|
+
// SEC-03:「未配置」有两种成因 —— 真的没配,或存过的地址被白名单拒掉(读侧丢弃)。
|
|
629
|
+
// 必须区分:后者如果说成前者的「尚未配置」,用户会一遍遍重填同一个被拒地址。
|
|
630
|
+
const configIssues: SyncConfigIssue[] = [];
|
|
631
|
+
const syncCfg = await this.readSyncConfigFn(channel, configIssues);
|
|
563
632
|
if (!syncIsConfigured(syncCfg)) {
|
|
633
|
+
const configIssue = describeConfigIssue(configIssues, channel);
|
|
634
|
+
if (configIssue !== undefined) this.reportConfigIssue(channel, configIssue);
|
|
564
635
|
const result: AutosyncRunResult = {
|
|
565
636
|
status: 'skipped', direction: 'none', skipReason: 'unconfigured', historyId,
|
|
566
637
|
consecutiveFailures: 0,
|
|
638
|
+
...(configIssue === undefined ? {} : { configIssue }),
|
|
567
639
|
};
|
|
568
640
|
await this.appendHistory(channel, {
|
|
569
641
|
direction: 'both',
|
|
@@ -571,6 +643,7 @@ export class AutoSyncScheduler {
|
|
|
571
643
|
skipReason: 'unconfigured',
|
|
572
644
|
createdAt: nowIso,
|
|
573
645
|
failureCountAtRun: 0,
|
|
646
|
+
...(configIssue === undefined ? {} : { configIssue }),
|
|
574
647
|
});
|
|
575
648
|
await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
|
|
576
649
|
return result;
|
|
@@ -615,6 +688,9 @@ export class AutoSyncScheduler {
|
|
|
615
688
|
// 列表拿不到(网络失败)时 remoteMetas 为 null → 检测自己再 list 一次,行为与改造前一致
|
|
616
689
|
const remoteNew = await this.detectRemoteNew(engine, remoteMetas ?? undefined);
|
|
617
690
|
const localDirty = await this.detectLocalChange(engine);
|
|
691
|
+
// SYN-05:首轮(从未跑过)与「远端有新快照」是覆盖风险最高的两个时刻 —— 由
|
|
692
|
+
// resolveConflictPushPolicy 统一把这两处的 ask 标成 forced。
|
|
693
|
+
const firstRun = cfg.lastRunAt === undefined || cfg.lastRunAt === '';
|
|
618
694
|
|
|
619
695
|
// 两端都无变化 → 什么都不做,记为 upToDate(不重复拉取/不空转)。
|
|
620
696
|
if (!remoteNew && !localDirty) {
|
|
@@ -722,13 +798,23 @@ export class AutoSyncScheduler {
|
|
|
722
798
|
if (!opts.startup && localDirty) {
|
|
723
799
|
try {
|
|
724
800
|
/**
|
|
725
|
-
* 冲突策略(conflictPolicy,缺省 '
|
|
801
|
+
* 冲突策略(conflictPolicy,缺省 'ask'):
|
|
726
802
|
* - 'ask':不强制,推送会被引擎的「远端已推进」前置检测挡下 → skipped/partial(conflict);
|
|
727
|
-
* - 'local'
|
|
728
|
-
* force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
|
|
803
|
+
* - 'local':用户显式选中才生效 —— **不写本地**(下行被冲突挡下),但把本机版本强制推成
|
|
804
|
+
* 新的远端最新。force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
|
|
729
805
|
* 被覆盖的远端旧版本留在远端历史快照里,可手动回滚。
|
|
806
|
+
*
|
|
807
|
+
* SYN-05:首轮 / 远端有新快照时即使显式选了 local 也强制降级 ask(forced=true 进结果与
|
|
808
|
+
* 历史),绝不在这两个高危时刻替用户覆盖远端最新。
|
|
730
809
|
*/
|
|
731
|
-
const
|
|
810
|
+
const pushDecision = resolveConflictPushPolicy({
|
|
811
|
+
...(cfg.conflictPolicy !== undefined ? { configuredPolicy: cfg.conflictPolicy } : {}),
|
|
812
|
+
firstRun,
|
|
813
|
+
remoteNew,
|
|
814
|
+
conflicted: conflictedSections.length,
|
|
815
|
+
});
|
|
816
|
+
const forcePush = pushDecision.force;
|
|
817
|
+
const forcedMark = pushDecision.forcedAsk ? { forced: true as const } : {};
|
|
732
818
|
// Phase C: 完整双向 → engine.push() 上传。P0-A:外部 push 包 intent journal。
|
|
733
819
|
const rawPush = async () => engine.push(forcePush ? { force: true } : {});
|
|
734
820
|
const pushReport = (this.phase3Recovery !== undefined && this.lockCtxForJournal !== null)
|
|
@@ -749,6 +835,7 @@ export class AutoSyncScheduler {
|
|
|
749
835
|
appliedSections,
|
|
750
836
|
...(conflictedSections.length > 0 ? { conflictedSections } : {}),
|
|
751
837
|
consecutiveFailures: 0,
|
|
838
|
+
...forcedMark,
|
|
752
839
|
};
|
|
753
840
|
await this.appendHistory(channel, {
|
|
754
841
|
direction: appliedSections.length ? 'both' : 'push', status: conflictResult.status,
|
|
@@ -756,6 +843,7 @@ export class AutoSyncScheduler {
|
|
|
756
843
|
appliedSections,
|
|
757
844
|
...(conflictedSections.length > 0 ? { conflictedSections } : {}),
|
|
758
845
|
createdAt: nowIso, failureCountAtRun: 0,
|
|
846
|
+
...forcedMark,
|
|
759
847
|
});
|
|
760
848
|
await this.writeFinalConfig(cfg, conflictResult, nowIso, historyId, channel);
|
|
761
849
|
return conflictResult;
|
|
@@ -799,6 +887,7 @@ export class AutoSyncScheduler {
|
|
|
799
887
|
...(skipReason !== undefined ? { skipReason } : {}),
|
|
800
888
|
...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
|
|
801
889
|
consecutiveFailures: 0,
|
|
890
|
+
...forcedMark,
|
|
802
891
|
};
|
|
803
892
|
await this.appendHistory(channel, {
|
|
804
893
|
direction: appliedSections.length ? 'both' : 'push',
|
|
@@ -807,6 +896,7 @@ export class AutoSyncScheduler {
|
|
|
807
896
|
...(skipReason !== undefined ? { skipReason } : {}),
|
|
808
897
|
...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
|
|
809
898
|
createdAt: nowIso, failureCountAtRun: 0,
|
|
899
|
+
...forcedMark,
|
|
810
900
|
});
|
|
811
901
|
await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
|
|
812
902
|
return result;
|
|
@@ -983,6 +1073,20 @@ export function syncIsConfigured(cfg: SyncConfig | null): boolean {
|
|
|
983
1073
|
return false;
|
|
984
1074
|
}
|
|
985
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* SEC-03:从读侧收集到的 issue 里挑出本通道的成因(`code(channel): message`),
|
|
1078
|
+
* 没有就返回 undefined —— 「真的没配」绝不伪造原因(无幻影告警)。
|
|
1079
|
+
* 多通道并存时只取本通道,git 的拒因不该挂在 webdav 的历史里。
|
|
1080
|
+
*/
|
|
1081
|
+
export function describeConfigIssue(
|
|
1082
|
+
issues: readonly SyncConfigIssue[],
|
|
1083
|
+
channel: SyncTransportType,
|
|
1084
|
+
): string | undefined {
|
|
1085
|
+
const own = issues.filter((issue) => issue.channel === channel);
|
|
1086
|
+
if (own.length === 0) return undefined;
|
|
1087
|
+
return own.map((issue) => `${issue.code} (${issue.channel}): ${issue.message}`).join('; ');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
986
1090
|
/**
|
|
987
1091
|
* 缺省远端新快照检测(§3.2):engine 实现了 hasNewRemoteSnapshot() → 调用;
|
|
988
1092
|
* 否则(测试 mock 未实现)保守返回 true(假设有新生,保持旧行为=每次都尝试拉取)。
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-03 可见性 + webdav username 回填:下沉模块 `config-issue-reporting.ts` 的行为契约。
|
|
3
|
+
*
|
|
4
|
+
* 这里守的两条都是**可见性语义**,不是实现细节:
|
|
5
|
+
* 1) 「同步配置被拒」的告警按 (通道/码/文案) 去重 —— prepareSync 每个同步请求都跑,
|
|
6
|
+
* 重复行会把日志刷成噪音;但不同被拒地址的文案不同,必须再报一次。
|
|
7
|
+
* 2) webdav 请求体没带 username 时从持久化配置回填(留空 = 沿用已保存凭据),
|
|
8
|
+
* 回填失败(读不到/读坏)保持原值交给下游校验,绝不静默改成别的通道配置。
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { createPrepareSync, createSyncConfigIssueReporter } from './config-issue-reporting.ts';
|
|
17
|
+
import { isGitConfig, isWebDavConfig, SYNC_CONFIG_FILE, type SyncConfig, type SyncConfigIssue } from './sync-config.ts';
|
|
18
|
+
|
|
19
|
+
const issue = (message: string, channel: SyncConfigIssue['channel'] = 'git'): SyncConfigIssue =>
|
|
20
|
+
({ code: 'repo-url-invalid', channel, message });
|
|
21
|
+
|
|
22
|
+
const webdavCfg = (username?: string): SyncConfig => ({
|
|
23
|
+
schemaVersion: 2,
|
|
24
|
+
transport: 'webdav',
|
|
25
|
+
webdav: username === undefined ? { url: 'https://dav.example.com/remote.php/dav' } : { url: 'https://dav.example.com/remote.php/dav', username },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const gitCfg = (repoUrl: string): SyncConfig => ({ schemaVersion: 2, transport: 'git', git: { repoUrl } });
|
|
29
|
+
|
|
30
|
+
/** 与宿主 `mergePersistedWebDavUsername` 同语义的最小实现(不 import 4400 行的入口模块)。 */
|
|
31
|
+
const mergeStub = (cfg: SyncConfig, persisted: SyncConfig | null): SyncConfig => {
|
|
32
|
+
if (persisted === null || persisted.transport !== 'webdav' || persisted.schemaVersion !== 2) return cfg;
|
|
33
|
+
if (cfg.transport !== 'webdav' || cfg.schemaVersion !== 2) return cfg;
|
|
34
|
+
const saved = persisted.webdav.username;
|
|
35
|
+
if (saved === undefined || saved === '') return cfg;
|
|
36
|
+
return { ...cfg, webdav: { ...cfg.webdav, username: saved } };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function tmpSyncDir(): Promise<string> {
|
|
40
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcm-issue-report-'));
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('SEC-03 报告器:同一 (通道/码/文案) 只输出一次,跨调用累计', () => {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
const report = createSyncConfigIssueReporter((line) => lines.push(line));
|
|
47
|
+
report([issue('private address')]);
|
|
48
|
+
report([issue('private address')]);
|
|
49
|
+
report([issue('private address'), issue('private address')]);
|
|
50
|
+
assert.deepEqual(lines, ['sync config rejected (git/repo-url-invalid): private address']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('SEC-03 报告器:不同文案或不同通道各自成一条(新信息不吞)', () => {
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
const report = createSyncConfigIssueReporter((line) => lines.push(line));
|
|
56
|
+
report([issue('private address 192.168.1.10'), issue('private address 10.0.0.5')]);
|
|
57
|
+
report([issue('private address 192.168.1.10', 'webdav')]);
|
|
58
|
+
assert.equal(lines.length, 3);
|
|
59
|
+
assert.deepEqual(lines, [
|
|
60
|
+
'sync config rejected (git/repo-url-invalid): private address 192.168.1.10',
|
|
61
|
+
'sync config rejected (git/repo-url-invalid): private address 10.0.0.5',
|
|
62
|
+
'sync config rejected (webdav/repo-url-invalid): private address 192.168.1.10',
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('SEC-03 报告器:空 issues 不产生任何输出(无幻影告警)', () => {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
createSyncConfigIssueReporter((line) => lines.push(line))([]);
|
|
69
|
+
assert.deepEqual(lines, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('prepareSync:webdav 未带 username → 从持久化配置回填(留空 = 沿用已保存凭据)', async () => {
|
|
73
|
+
const dir = await tmpSyncDir();
|
|
74
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
|
|
75
|
+
schemaVersion: 3,
|
|
76
|
+
transport: 'webdav',
|
|
77
|
+
webdav: { url: 'https://dav.example.com/remote.php/dav', username: 'saved-user' },
|
|
78
|
+
}), 'utf8');
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
const prepareSync = createPrepareSync({
|
|
81
|
+
syncDir: dir,
|
|
82
|
+
parseBody: async () => webdavCfg(),
|
|
83
|
+
mergeWebDavUsername: mergeStub,
|
|
84
|
+
log: (line) => lines.push(line),
|
|
85
|
+
});
|
|
86
|
+
const cfg = await prepareSync({});
|
|
87
|
+
assert.ok(isWebDavConfig(cfg));
|
|
88
|
+
assert.equal(cfg.webdav.username, 'saved-user');
|
|
89
|
+
assert.deepEqual(lines, []);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('prepareSync:已存 repoUrl 被白名单拒绝 → 日志报成因,且按通道/文案去重(两次调用只一行)', async () => {
|
|
93
|
+
const dir = await tmpSyncDir();
|
|
94
|
+
await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), JSON.stringify({
|
|
95
|
+
schemaVersion: 3,
|
|
96
|
+
transport: 'git',
|
|
97
|
+
git: { repoUrl: 'https://192.168.1.10/owner/repo.git' },
|
|
98
|
+
}), 'utf8');
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
const prepareSync = createPrepareSync({
|
|
101
|
+
syncDir: dir,
|
|
102
|
+
parseBody: async () => webdavCfg(),
|
|
103
|
+
mergeWebDavUsername: mergeStub,
|
|
104
|
+
log: (line) => lines.push(line),
|
|
105
|
+
});
|
|
106
|
+
await prepareSync({});
|
|
107
|
+
await prepareSync({});
|
|
108
|
+
assert.equal(lines.length, 1, '同一成因重复请求也只能报一次');
|
|
109
|
+
assert.match(lines[0]!, /^sync config rejected \(git\/repo-url-invalid\): /);
|
|
110
|
+
assert.match(lines[0]!, /192\.168\.1\.10/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('prepareSync:请求体已带 username → 不读盘、不改值(git 通道同样原样返回)', async () => {
|
|
114
|
+
const dir = await tmpSyncDir();
|
|
115
|
+
let merged = 0;
|
|
116
|
+
const prepareSync = createPrepareSync({
|
|
117
|
+
syncDir: dir,
|
|
118
|
+
parseBody: async () => webdavCfg('explicit-user'),
|
|
119
|
+
mergeWebDavUsername: (cfg, persisted) => { merged += 1; return mergeStub(cfg, persisted); },
|
|
120
|
+
log: () => {},
|
|
121
|
+
});
|
|
122
|
+
const cfg = await prepareSync({});
|
|
123
|
+
assert.ok(isWebDavConfig(cfg));
|
|
124
|
+
assert.equal(cfg.webdav.username, 'explicit-user');
|
|
125
|
+
assert.equal(merged, 0);
|
|
126
|
+
|
|
127
|
+
const gitPrepare = createPrepareSync({
|
|
128
|
+
syncDir: dir,
|
|
129
|
+
parseBody: async () => gitCfg('https://github.com/owner/repo.git'),
|
|
130
|
+
mergeWebDavUsername: () => { throw new Error('git 通道不该走 webdav 回填'); },
|
|
131
|
+
log: () => {},
|
|
132
|
+
});
|
|
133
|
+
const git = await gitPrepare({});
|
|
134
|
+
assert.ok(isGitConfig(git));
|
|
135
|
+
assert.equal(git.git.repoUrl, 'https://github.com/owner/repo.git');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('prepareSync:持久化配置缺失 → 保持请求体原值(不猜、不改通道)', async () => {
|
|
139
|
+
const dir = await tmpSyncDir();
|
|
140
|
+
const prepareSync = createPrepareSync({
|
|
141
|
+
syncDir: dir,
|
|
142
|
+
parseBody: async () => webdavCfg(),
|
|
143
|
+
mergeWebDavUsername: mergeStub,
|
|
144
|
+
log: () => {},
|
|
145
|
+
});
|
|
146
|
+
const cfg = await prepareSync({});
|
|
147
|
+
assert.deepEqual(cfg, webdavCfg());
|
|
148
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步请求准备期的两个只读旁路(从宿主壳 `src/index.ts` 下沉,行为逐字保持)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立成模块:
|
|
5
|
+
* 1) `prepareSync` 每个同步请求都跑一次(含挂载时 snapshotsList 的自动加载),它的副作用
|
|
6
|
+
* (回填 webdav username、把「配置被拒」告警一次)需要自己的单测,而宿主壳本身不可实例化;
|
|
7
|
+
* 2) 去重语义是**可见性契约**的一部分(同一 (通道/码/文案) 只报一次),写在 4400 行的
|
|
8
|
+
* assembly 文件里没人守得住。
|
|
9
|
+
*
|
|
10
|
+
* 这两件事共享同一个来源:读已落盘的 sync-config。读出来的产物有两种用途 —— 回填缺失字段、
|
|
11
|
+
* 报告被读侧丢弃的成因,因此放在同一个模块里,但各自独立导出、独立可测。
|
|
12
|
+
*/
|
|
13
|
+
import { isWebDavConfig, readSyncConfig } from './sync-config.ts';
|
|
14
|
+
import type { SyncConfig, SyncConfigIssue } from './sync-config.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 造一个「配置被拒」报告器:返回的函数幂等 —— 同一 (通道/码/文案) 只输出一行。
|
|
18
|
+
*
|
|
19
|
+
* 去重理由:调用方每次同步请求都跑一遍,按请求打日志会被零信息量的重复行刷满;而
|
|
20
|
+
* 「地址被拒」是**持久状态**,报一次即可。键里带 message 而不只带 code:不同被拒地址的
|
|
21
|
+
* 成因文案不同,那是新的有用信息,用户改了配置再被拒应当再报一次。
|
|
22
|
+
*
|
|
23
|
+
* `log` 由调用方注入(宿主传 `host.log.warn`),本模块不碰日志设施与 i18n 字典。
|
|
24
|
+
*/
|
|
25
|
+
export function createSyncConfigIssueReporter(
|
|
26
|
+
log: (line: string) => void,
|
|
27
|
+
): (issues: readonly SyncConfigIssue[]) => void {
|
|
28
|
+
const reported = new Set<string>();
|
|
29
|
+
return (issues) => {
|
|
30
|
+
for (const issue of issues) {
|
|
31
|
+
const key = issue.channel + '/' + issue.code + '/' + issue.message;
|
|
32
|
+
if (reported.has(key)) continue;
|
|
33
|
+
reported.add(key);
|
|
34
|
+
log('sync config rejected (' + issue.channel + '/' + issue.code + '): ' + issue.message);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `createPrepareSync` 的依赖(全部注入,便于单测与避免与 index.ts 形成循环依赖)。 */
|
|
40
|
+
export interface PrepareSyncOptions {
|
|
41
|
+
/** 同步配置目录(`sync-config.json` 所在处)。 */
|
|
42
|
+
syncDir: string;
|
|
43
|
+
/** 解析请求体(宿主传入带 credentials 的 closure)。 */
|
|
44
|
+
parseBody: (body: Record<string, unknown>) => Promise<SyncConfig>;
|
|
45
|
+
/** 把已保存的 webdav username 回填进请求体(宿主传入 `mergePersistedWebDavUsername`)。 */
|
|
46
|
+
mergeWebDavUsername: (cfg: SyncConfig, persisted: SyncConfig | null) => SyncConfig;
|
|
47
|
+
/** 告警出口(宿主传 `(line) => host.log.warn(line)`)。 */
|
|
48
|
+
log: (line: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 造 `prepareSync`:解析请求体,webdav 请求体未带 username 时从持久化配置回填 ——
|
|
53
|
+
* 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
|
|
54
|
+
* 语义与 password 一致:留空 = 沿用已保存凭据。
|
|
55
|
+
*
|
|
56
|
+
* SEC-03:顺带把「已落盘但过不了白名单、被读取侧丢弃」的 repoUrl 成因报一次;界面侧的
|
|
57
|
+
* 权威出口是 GET /sync/status 的 `configIssues`(字段恒在、按请求实时算),这里只补日志。
|
|
58
|
+
* 读配置失败时保持原值(空 username 由 WebDavTransport 构造校验兜底报错)。
|
|
59
|
+
*/
|
|
60
|
+
export function createPrepareSync(opts: PrepareSyncOptions): (body: Record<string, unknown>) => Promise<SyncConfig> {
|
|
61
|
+
const reportIssues = createSyncConfigIssueReporter(opts.log);
|
|
62
|
+
return async (body) => {
|
|
63
|
+
const cfg = await opts.parseBody(body);
|
|
64
|
+
if (!isWebDavConfig(cfg)) return cfg;
|
|
65
|
+
if (cfg.webdav.username !== undefined && cfg.webdav.username !== '') return cfg;
|
|
66
|
+
try {
|
|
67
|
+
const issues: SyncConfigIssue[] = [];
|
|
68
|
+
const persisted = await readSyncConfig(opts.syncDir, issues);
|
|
69
|
+
reportIssues(issues);
|
|
70
|
+
return opts.mergeWebDavUsername(cfg, persisted);
|
|
71
|
+
} catch {
|
|
72
|
+
return cfg;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gc-skip.ts 单测(SYN-04b:通道「本轮 GC 放弃码」的读取判据与冒泡)。
|
|
3
|
+
*
|
|
4
|
+
* 行为契约(本文件钉死):
|
|
5
|
+
* - 只有**非空字符串**算「有放弃码」;字段缺失 / null / 数字 / 空串 / getter 抛错 一律当没有;
|
|
6
|
+
* - 冒泡进 warnings,且同一轮只报一次;码在文本里必须明文出现(字典未补键时也能 grep)。
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { gcSkipWarning, readGcSkipCode, reportGcSkipOnce } from './gc-skip.ts';
|
|
11
|
+
import type { MsgFunc } from '../core/messages.ts';
|
|
12
|
+
|
|
13
|
+
/** 造一个「像 transport」的对象:getter 形态与 webdav/s3 通道一致(可配置抛错)。 */
|
|
14
|
+
function withLastGc(spec: { value?: unknown; throw?: boolean }): unknown {
|
|
15
|
+
const obj = {};
|
|
16
|
+
Object.defineProperty(obj, 'lastGcSkip', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
get() {
|
|
19
|
+
if (spec.throw === true) throw new Error('getter exploded');
|
|
20
|
+
return spec.value;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return obj;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('gc-skip/①:只有非空字符串算放弃码,其余形态一律当没有', () => {
|
|
27
|
+
assert.equal(readGcSkipCode(withLastGc({ value: 'gc-skip-reference-missing' })), 'gc-skip-reference-missing');
|
|
28
|
+
assert.equal(readGcSkipCode(withLastGc({ value: '' })), null);
|
|
29
|
+
assert.equal(readGcSkipCode(withLastGc({ value: null })), null);
|
|
30
|
+
assert.equal(readGcSkipCode(withLastGc({ value: 42 })), null);
|
|
31
|
+
assert.equal(readGcSkipCode(withLastGc({ value: { code: 'x' } })), null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('gc-skip/②:字段缺失与 getter 抛错都当没有(诊断能力绝不影响 push)', () => {
|
|
35
|
+
assert.equal(readGcSkipCode({}), null);
|
|
36
|
+
assert.equal(readGcSkipCode(null), null);
|
|
37
|
+
assert.equal(readGcSkipCode(undefined), null);
|
|
38
|
+
assert.equal(readGcSkipCode(withLastGc({ throw: true })), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('gc-skip/③:字典未补键时码以明文兜底(可 grep),补键后不重复追加', () => {
|
|
42
|
+
const bare: MsgFunc = (key) => key;
|
|
43
|
+
const text = gcSkipWarning(bare, 'gc-skip-snapshot-unparsable');
|
|
44
|
+
assert.equal(text, 'sync.gcSkipped: gc-skip-snapshot-unparsable');
|
|
45
|
+
|
|
46
|
+
const filled: MsgFunc = () => '本轮已放弃回收远端孤立文件(gc-skip-reference-missing)';
|
|
47
|
+
assert.equal(gcSkipWarning(filled, 'gc-skip-reference-missing'), '本轮已放弃回收远端孤立文件(gc-skip-reference-missing)');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('gc-skip/④:同一轮只报一次;无码 / 非法形态零告警', () => {
|
|
51
|
+
const msg: MsgFunc = (key, vars) => `${key}(${String((vars as { code?: string } | undefined)?.code ?? '')})`;
|
|
52
|
+
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
const state = { done: false };
|
|
55
|
+
const transport = withLastGc({ value: 'gc-skip-reference-missing' });
|
|
56
|
+
reportGcSkipOnce(transport, msg, warnings, state);
|
|
57
|
+
reportGcSkipOnce(transport, msg, warnings, state);
|
|
58
|
+
reportGcSkipOnce(transport, msg, warnings, state);
|
|
59
|
+
assert.equal(warnings.length, 1);
|
|
60
|
+
assert.equal(state.done, true);
|
|
61
|
+
|
|
62
|
+
const quiet: string[] = [];
|
|
63
|
+
reportGcSkipOnce({}, msg, quiet, { done: false });
|
|
64
|
+
reportGcSkipOnce(withLastGc({ value: 0 }), msg, quiet, { done: false });
|
|
65
|
+
assert.deepEqual(quiet, []);
|
|
66
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { MsgFunc } from '../core/messages.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 本轮 blob GC 的放弃码(SYN-04)。**机器可判定**,文案由上层按码翻译(字典键由 Lead 统一加):
|
|
5
|
+
* 通道没有 logger,`delete()` 还会吞掉 GC 抛出的异常,所以「本轮为什么没回收」只能靠这个码如实外露。
|
|
6
|
+
* 三态同义:拿不到引用者内容 = 无法证明「没人引用」⇒ 本轮整轮放弃,绝不按残集删除。
|
|
7
|
+
*
|
|
8
|
+
* 单一来源:webdav / s3 两个通道都从这里导入(此前各写一份同名常量,改一侧不炸另一侧 =
|
|
9
|
+
* 静默漂移;本模块不 import 任何 transport,所以不存在环)。
|
|
10
|
+
*/
|
|
11
|
+
export const GC_SKIP_REFERENCE_MISSING = 'gc-skip-reference-missing'
|
|
12
|
+
export const GC_SKIP_REFERENCE_UNREADABLE = 'gc-skip-reference-unreadable'
|
|
13
|
+
export const GC_SKIP_SNAPSHOT_UNPARSABLE = 'gc-skip-snapshot-unparsable'
|
|
14
|
+
|
|
15
|
+
/** blob GC 放弃码(SYN-04)。 */
|
|
16
|
+
export type GcSkipCode =
|
|
17
|
+
| typeof GC_SKIP_REFERENCE_MISSING
|
|
18
|
+
| typeof GC_SKIP_REFERENCE_UNREADABLE
|
|
19
|
+
| typeof GC_SKIP_SNAPSHOT_UNPARSABLE
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SYN-04b:通道「本轮 GC 放弃码」的读取与冒泡(从 sync-engine.ts 下沉,行为逐字不变)。
|
|
23
|
+
*
|
|
24
|
+
* 为什么放在独立模块:SYN-04 之后 webdav/s3 遇到「引用快照 404 / 读不出」会**放弃本轮 blob GC**
|
|
25
|
+
* (绝不在「证明不了没人引用」时删 blob),而通道层没有 logger、delete() 又把 GC 异常吃掉
|
|
26
|
+
* ⇒ 放弃只能靠 transport 的只读字段外露。字段形态是 getter(见 webdav/s3 transport 的
|
|
27
|
+
* `get lastGcSkip()`),这里按 unknown 读、只接受非空字符串,getter 抛错也不影响 push。
|
|
28
|
+
*
|
|
29
|
+
* 判据(缺一不可,全部当「没有放弃码」):字段缺失 / null / 非字符串 / 空串 / getter 抛错。
|
|
30
|
+
* 读不到 ≠ 没有放弃:通道没这个能力时本来就不该冒泡告警,所以两者在调用侧合并处理。
|
|
31
|
+
*/
|
|
32
|
+
export function readGcSkipCode(transport: unknown): string | null {
|
|
33
|
+
try {
|
|
34
|
+
const raw = (transport as { lastGcSkip?: unknown } | null | undefined)?.lastGcSkip
|
|
35
|
+
return typeof raw === 'string' && raw !== '' ? raw : null
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SYN-04b:放弃码 → 一条 warning 文本。
|
|
43
|
+
*
|
|
44
|
+
* 码必须同时以**明文**出现:core 目录的翻译器对未知键回退键名本身({code} 插值随之丢失),
|
|
45
|
+
* 而文案键由上层统一补字典 —— 在补键之前,只发出裸键名等于「报了但看不出来」,日志也无法 grep。
|
|
46
|
+
*/
|
|
47
|
+
export function gcSkipWarning(msg: MsgFunc, code: string): string {
|
|
48
|
+
const text = msg('sync.gcSkipped', { code })
|
|
49
|
+
return text.includes(code) ? text : text + ': ' + code
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* SYN-04b:把通道的 GC 放弃码冒泡成一条 warning(`state` 保证同一轮只报一次)。
|
|
54
|
+
*
|
|
55
|
+
* 语义边界:放弃 GC **不是** push 失败 —— 快照本身已上传/已删除,只是这一轮的孤儿 blob
|
|
56
|
+
* 没被回收(磁盘/远端会多留几份文件)。所以它进 warnings、不进 ok,与 prune 既有语义一致。
|
|
57
|
+
*/
|
|
58
|
+
export function reportGcSkipOnce(transport: unknown, msg: MsgFunc, warnings: string[], state: { done: boolean }): void {
|
|
59
|
+
if (state.done) return
|
|
60
|
+
const code = readGcSkipCode(transport)
|
|
61
|
+
if (code === null) return
|
|
62
|
+
state.done = true
|
|
63
|
+
warnings.push(gcSkipWarning(msg, code))
|
|
64
|
+
}
|
|
@@ -48,7 +48,12 @@ test('git probe:匿名 ls-remote 成功 → 连接正常,且**只**发这一
|
|
|
48
48
|
assert.equal(result.snapshotCount, undefined, '不得报快照数(统计它需要 fetch 工作副本)');
|
|
49
49
|
assert.deepEqual(result.createdCollections ?? [], [], 'git 通道不在远端建目录');
|
|
50
50
|
assert.equal(calls.length, 1, '探针只准发一条 ls-remote');
|
|
51
|
-
|
|
51
|
+
// 前置的 `-c <key=value>` 是网络低速上限(issue #122)与协议护栏(SEC-12),不属于命令本体
|
|
52
|
+
const argv = calls[0]!;
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (argv[i] === '-c') i += 2;
|
|
55
|
+
// `--` 是 SEC-12 的选项终止符:repoUrl 恒定位在 `--` 之后,永不被当成 git 选项解析
|
|
56
|
+
assert.deepEqual(argv.slice(i), ['ls-remote', '--', REPO]);
|
|
52
57
|
});
|
|
53
58
|
|
|
54
59
|
test('git probe:匿名被拒、带凭据成功 → 连接正常(私有仓库 + 凭据可用)', async () => {
|