dsh-config-manager 0.2.4 → 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 +1263 -1192
- 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 +10 -0
- package/lib/sync/git/git-transport.js +72 -14
- 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/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 +3 -2
- package/src/sync/git/git-transport.test.ts +168 -7
- package/src/sync/git/git-transport.ts +70 -14
- 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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-05(表外条目)的**唯一**判定点:把「未登记进校验表、却会被落盘」的文件分区计划项
|
|
3
|
+
* 拦在执行之前。
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独成模块:
|
|
6
|
+
* - 判据落在一处才审得动 —— 文件前缀组合、会写盘的 kind、目录/结构文件排除,任何一处改口径都
|
|
7
|
+
* 同时影响「告警」与「阻断」,两边分叉就等于自己骗自己;
|
|
8
|
+
* - `src/core/analyzer.ts` 有体量棘轮(tests/file-size-ratchet.test.ts),这类判定应下沉到
|
|
9
|
+
* `src/security/`,而不是让分析器继续变胖。
|
|
10
|
+
*
|
|
11
|
+
* 本模块**不**反向依赖 `src/core/*` 的值:只声明用到的字段结构(`PlanItem` 结构可赋值),
|
|
12
|
+
* 消息翻译走注入的 `MsgFunc`,因此不与 core 形成环。
|
|
13
|
+
*/
|
|
14
|
+
import { SECTION_FILE_PREFIXES } from '../schema/config.js';
|
|
15
|
+
import { CHECKSUMS_FILE, MANIFEST_FILE } from '../schema/manifest.js';
|
|
16
|
+
import { parseJsonSafe } from '../utils/json.js';
|
|
17
|
+
import { verifyChecksums } from '../utils/hashing.js';
|
|
18
|
+
import { UNVERIFIED_FILE_ENTRY_CODE, UNVERIFIED_FILE_ENTRY_KEY, unverifiedEntries } from './integrity.js';
|
|
19
|
+
/**
|
|
20
|
+
* SEC-05 文案:优先用专属键 `import.unverifiedFileEntry`。
|
|
21
|
+
*
|
|
22
|
+
* 字典还没收录该键时 `msg` 会回退成键名本身(messages.ts 的「未知键绝不抛错」约定),
|
|
23
|
+
* 那样文案里只有键名、**连是哪个文件都没说** —— 对用户是最糟的形态。因此回退到既有的
|
|
24
|
+
* `import.extraEntries`(已翻译、也带条目名)。两段指向同一事实:「这个条目没被校验表覆盖」。
|
|
25
|
+
*/
|
|
26
|
+
/** 单条文案最多列几个条目 —— 校验表缺失时名单 = 整包文件分区条目,不截断会撑爆一条 warning。 */
|
|
27
|
+
const MAX_LISTED_ENTRIES = 10;
|
|
28
|
+
export function unverifiedFileEntryText(text, names) {
|
|
29
|
+
return render(text, UNVERIFIED_FILE_ENTRY_KEY, names);
|
|
30
|
+
}
|
|
31
|
+
function render(text, key, names) {
|
|
32
|
+
const head = names.slice(0, MAX_LISTED_ENTRIES).map((n) => `"${n}"`).join(', ');
|
|
33
|
+
const list = names.length > MAX_LISTED_ENTRIES ? `${head} …(+${names.length - MAX_LISTED_ENTRIES})` : head;
|
|
34
|
+
const rendered = text(key, { entries: list });
|
|
35
|
+
return rendered === key ? text('import.extraEntries', { entries: list }) : rendered;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 未覆盖条目的**唯一**一条告警(RV1-F11:同一事实只发一次,不再与 `import.extraEntries` 并存)。
|
|
39
|
+
*
|
|
40
|
+
* 键按「到底拦没拦」选,避免文案自称做了没做的事:
|
|
41
|
+
* - 真拦下了(blocked > 0)⇒ 专属键 `import.unverifiedFileEntry`(「已逐条阻断写入」);
|
|
42
|
+
* - 一个都没拦(豁免分区 / 不在任何文件分区前缀下的散落条目,守 INT-01)⇒ 中性口径 `import.extraEntries`。
|
|
43
|
+
*/
|
|
44
|
+
export function unverifiedNoticeText(text, names, blocked) {
|
|
45
|
+
return render(text, blocked > 0 ? UNVERIFIED_FILE_ENTRY_KEY : 'import.extraEntries', names);
|
|
46
|
+
}
|
|
47
|
+
/** 归档里的结构文件(不属于内容,永不参与「未覆盖」判定)。 */
|
|
48
|
+
const STRUCTURAL_FILES = [MANIFEST_FILE, CHECKSUMS_FILE];
|
|
49
|
+
/** 落在文件分区前缀下的归档条目(目录标记条目与结构文件排除)。 */
|
|
50
|
+
function fileSectionEntries(names) {
|
|
51
|
+
const skip = new Set(STRUCTURAL_FILES);
|
|
52
|
+
const prefixes = Object.values(SECTION_FILE_PREFIXES).filter((prefix) => prefix !== '');
|
|
53
|
+
return names.filter((n) => !n.endsWith('/') && !skip.has(n) && prefixes.some((p) => n.startsWith(p)));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 校验表**覆盖不到**的条目(反向覆盖)。
|
|
57
|
+
*
|
|
58
|
+
* - 表存在且非空:表里没登记的条目(`unverifiedEntries` 的判据,正向校验看不到它们);
|
|
59
|
+
* - 表**缺失/为空**:一个条目都没被校验 ⇒ 文件分区前缀下的条目一律视为未覆盖。
|
|
60
|
+
* 这是 RV1-F1 抓到的绕过:删掉 `integrity/checksums.json`(或置 `{}`)就等于把 SEC-05 关掉。
|
|
61
|
+
* 口径选「逐条 fail-closed」而非整包拒:旧包/手工包的非文件分区(settings 等)照常导入,
|
|
62
|
+
* 被拦下的只是「按归档名原样落盘、完整性声明又完全管不到」的那部分。
|
|
63
|
+
*
|
|
64
|
+
* 文案不在这里出(RV1-F11):同一事实只由 `analyzeImport` 发一条 `import.unverifiedFileEntry`。
|
|
65
|
+
*/
|
|
66
|
+
export function scanUnverifiedEntries(names, table) {
|
|
67
|
+
if (table === null || Object.keys(table).length === 0)
|
|
68
|
+
return fileSectionEntries(names);
|
|
69
|
+
return unverifiedEntries(names, table, STRUCTURAL_FILES);
|
|
70
|
+
}
|
|
71
|
+
/** 会写盘的计划项 kind(Skip 本来就不写目标,改成 Error 只会让无害 no-op 显示成失败)。 */
|
|
72
|
+
const WRITING_KINDS = new Set(['Create', 'Update', 'Conflict']);
|
|
73
|
+
/**
|
|
74
|
+
* 把命中「表外 + 会写盘」的文件分区计划项升级为 Error。
|
|
75
|
+
*
|
|
76
|
+
* 为什么必须拦:文件集合分区按归档名原样落盘(`adapters/file-collection.ts` 的 applyItem 只看
|
|
77
|
+
* `target.ref`),而 `verifyAgainstTable` 只正向校验表里登记的条目 —— 表外文件一路绿灯。
|
|
78
|
+
* 拦在执行之前、拦在计划项上(kind:'Error'):`applyOne` 遇到 Error 直接记 failed、绝不写目标,
|
|
79
|
+
* 用户也能在预览里看到「为什么这条没导」。
|
|
80
|
+
*
|
|
81
|
+
* @returns 被阻断的归档条目名(按计划项顺序,供调用方汇总一条提示)
|
|
82
|
+
*/
|
|
83
|
+
export function applyUnverifiedFileBlocks(unverified, items, fileSections, exemptSections, text) {
|
|
84
|
+
if (unverified.length === 0)
|
|
85
|
+
return [];
|
|
86
|
+
const unverifiedSet = new Set(unverified);
|
|
87
|
+
const exempt = new Set(exemptSections);
|
|
88
|
+
const blocked = [];
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
if (!fileSections.has(item.adapter) || exempt.has(item.adapter))
|
|
91
|
+
continue;
|
|
92
|
+
if (!WRITING_KINDS.has(item.kind))
|
|
93
|
+
continue;
|
|
94
|
+
const ref = item.target?.ref;
|
|
95
|
+
if (ref === undefined)
|
|
96
|
+
continue;
|
|
97
|
+
const archiveName = (SECTION_FILE_PREFIXES[item.adapter] ?? '') + ref;
|
|
98
|
+
if (!unverifiedSet.has(archiveName))
|
|
99
|
+
continue;
|
|
100
|
+
item.kind = 'Error';
|
|
101
|
+
item.severity = 'error';
|
|
102
|
+
item.detail = UNVERIFIED_FILE_ENTRY_CODE;
|
|
103
|
+
item.description = unverifiedFileEntryText(text, [archiveName]);
|
|
104
|
+
blocked.push(archiveName);
|
|
105
|
+
}
|
|
106
|
+
return blocked;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 归档完整性扫描:正向(表内每条必须存在且 SHA-256 一致)+ 反向(ZIP 有、表里没登记)。
|
|
110
|
+
*
|
|
111
|
+
* 正向用 `utils/hashing.ts` 的 `verifyChecksums` —— 与 `import-planning.ts` 里的同名判据逐字等价,
|
|
112
|
+
* 是同一份语义的规范实现,此处不再复制第二遍。
|
|
113
|
+
* 判定却必须与导入阻断共用(`scanUnverifiedEntries` / `unverifiedEntries`),否则「告警说的表外条目」
|
|
114
|
+
* 与「真正被拦下的条目」会分叉。
|
|
115
|
+
*/
|
|
116
|
+
export async function scanArchiveIntegrity(archive, text) {
|
|
117
|
+
const warnings = [];
|
|
118
|
+
const names = archive.names();
|
|
119
|
+
// 校验表缺失或为空:全部条目都处于「未登记 ⇒ 未被校验」,两种形态对用户是同一件事,共用一条告警。
|
|
120
|
+
// 此时 checksums 恒 ok(没有可正向校验的声明),防线全靠下一行 scanUnverifiedEntries 的 fail-closed。
|
|
121
|
+
const table = archive.has(CHECKSUMS_FILE)
|
|
122
|
+
? parseJsonSafe(archive.readEntryText(CHECKSUMS_FILE))
|
|
123
|
+
: null;
|
|
124
|
+
if (table === null || Object.keys(table).length === 0)
|
|
125
|
+
warnings.push(text('import.checksumsMissing'));
|
|
126
|
+
let checksums = { ok: true, mismatches: [], missing: [] };
|
|
127
|
+
if (table !== null && Object.keys(table).length > 0) {
|
|
128
|
+
const entries = new Map();
|
|
129
|
+
// 异步解压:这是全仓唯一逐条解压全部条目的地方(`inflateRawSync` 会整段阻塞事件循环)。
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
if (name === MANIFEST_FILE || name === CHECKSUMS_FILE)
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
entries.set(name, await archive.readEntryAsync(name));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
entries.delete(name); // 损坏条目在完整性阶段即失败(最终体现为 missing)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
checksums = verifyChecksums(entries, table);
|
|
141
|
+
}
|
|
142
|
+
// 反向覆盖只出条目名,文案统一由 analyzeImport 发一条(RV1-F11:同一事实不重复告警)。
|
|
143
|
+
const unverified = scanUnverifiedEntries(names, table);
|
|
144
|
+
const failure = checksums.ok
|
|
145
|
+
? undefined
|
|
146
|
+
: text('import.integrityFailed', {
|
|
147
|
+
entries: [...checksums.mismatches, ...checksums.missing].map((m) => `"${m}"`).join(', '),
|
|
148
|
+
});
|
|
149
|
+
return { checksums, unverified, warnings, ...(failure !== undefined ? { failure } : {}) };
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=unverified-file-blocks.js.map
|
|
@@ -21,15 +21,19 @@ export type AutosyncAutoApplyTier = 'low' | 'medium';
|
|
|
21
21
|
export declare const DEFAULT_AUTOSYNC_AUTO_APPLY_TIER: AutosyncAutoApplyTier;
|
|
22
22
|
/**
|
|
23
23
|
* 双侧都改(真冲突)时的自动裁决策略:
|
|
24
|
-
* - 'ask'
|
|
25
|
-
* - 'local'
|
|
26
|
-
*
|
|
24
|
+
* - 'ask' :缺省 —— 保留冲突待人工裁决,冲突期间**零写入**(下行不动、上行不 force);
|
|
25
|
+
* - 'local' :用户显式选中才生效 —— **保留本机并推上去**(VS Code 风格):本机版本成为新的
|
|
26
|
+
* 远端最新,被覆盖的远端版本仍留在远端历史快照里,随时可回滚。
|
|
27
|
+
*
|
|
28
|
+
* SYN-05 为什么把缺省从 'local' 改成 'ask':'local' 缺省会在「首轮」与「远端刚出现新快照」
|
|
29
|
+
* 这两个高危时刻静默用本机覆盖远端最新(用户根本没看见那段远端改动就被盖掉)。缺省必须选
|
|
30
|
+
* **安全且可逆**的一侧 = ask;想覆盖必须自己选,且首轮 / 远端有新快照时仍降级为 ask(forced)。
|
|
27
31
|
*
|
|
28
32
|
* 刻意**不提供** 'remote':采用远端需要走 applyMergeResolutions,那条路径会整体应用所有
|
|
29
33
|
* 可决断分区、绕过 risk.ts 的风险分级(audit-sync sync-F5),与「分区逐档放行」冲突。
|
|
30
34
|
*/
|
|
31
35
|
export type AutosyncConflictPolicy = 'ask' | 'local';
|
|
32
|
-
/** 缺省冲突策略:local
|
|
36
|
+
/** 缺省冲突策略:ask(冲突待人工裁决;显式写过 local 的旧配置仍按 local 生效) */
|
|
33
37
|
export declare const DEFAULT_AUTOSYNC_CONFLICT_POLICY: AutosyncConflictPolicy;
|
|
34
38
|
/** 最近一次自动同步执行状态 */
|
|
35
39
|
export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
|
|
@@ -35,8 +35,8 @@ export const MIN_AUTOSYNC_REMOTE_CHECK_MS = 15 * 1000;
|
|
|
35
35
|
export const MAX_AUTOSYNC_REMOTE_CHECK_MS = 60 * 60 * 1000;
|
|
36
36
|
/** 缺省自动应用等级:medium(用户拍板:plugins/mcp 的中风险改动也要真自动,否则「同步所有改动」是假的) */
|
|
37
37
|
export const DEFAULT_AUTOSYNC_AUTO_APPLY_TIER = 'medium';
|
|
38
|
-
/** 缺省冲突策略:local
|
|
39
|
-
export const DEFAULT_AUTOSYNC_CONFLICT_POLICY = '
|
|
38
|
+
/** 缺省冲突策略:ask(冲突待人工裁决;显式写过 local 的旧配置仍按 local 生效) */
|
|
39
|
+
export const DEFAULT_AUTOSYNC_CONFLICT_POLICY = 'ask';
|
|
40
40
|
/** 缺省配置(首次无文件 / 损坏 / 不支持 schema 时回退) */
|
|
41
41
|
export function defaultAutosyncConfig() {
|
|
42
42
|
return {
|
|
@@ -7,9 +7,10 @@ import type { SyncEngine } from './sync-engine.ts';
|
|
|
7
7
|
import type { SyncSnapshotMeta } from './transport.ts';
|
|
8
8
|
import type { MutationLockPort, MutationLockContext } from '../utils/env-lock.ts';
|
|
9
9
|
import type { AutosyncConfig, AutosyncInterval } from './autosync-config.ts';
|
|
10
|
-
import type { SyncConfig, SyncTransportType } from './sync-config.ts';
|
|
10
|
+
import type { SyncConfig, SyncConfigIssue, SyncTransportType } from './sync-config.ts';
|
|
11
11
|
import { readSyncHistory } from './sync-history.ts';
|
|
12
12
|
import type { AutosyncHistoryEntry } from './sync-history.ts';
|
|
13
|
+
import type { AutosyncConflictPolicy } from './autosync-config.ts';
|
|
13
14
|
import type { MergePlan } from './merge.ts';
|
|
14
15
|
import type { SyncApplyPlan } from './risk.ts';
|
|
15
16
|
import { SyncWatcher } from './sync-watcher.ts';
|
|
@@ -32,7 +33,40 @@ export interface AutosyncRunResult {
|
|
|
32
33
|
error?: string;
|
|
33
34
|
historyId: string;
|
|
34
35
|
consecutiveFailures: number;
|
|
36
|
+
/**
|
|
37
|
+
* SYN-05:本轮 ask 是**被强制**的(首轮或远端有新快照),不是用户选的 ask。
|
|
38
|
+
* 界面据此说清「这次没让你选」,避免用户以为自己的 local 设置失效了。
|
|
39
|
+
*/
|
|
40
|
+
forced?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* SEC-03:本轮 skipReason='unconfigured' 时,已落盘配置为何被读侧丢弃(地址被白名单拒绝)。
|
|
43
|
+
* 缺省 undefined = 真的没配。有值就必须让用户看见 —— 否则「保存过却被拒」和
|
|
44
|
+
* 「从来没配」在界面上长得一模一样,用户会反复重填同一个被拒地址。
|
|
45
|
+
*/
|
|
46
|
+
configIssue?: string;
|
|
35
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* SYN-05:本轮冲突(下行冲突)该不该 force push —— 唯一判定点。
|
|
50
|
+
*
|
|
51
|
+
* 规则(缺省 ask 之上再加两道高危闸):
|
|
52
|
+
* - 用户没选过策略 → 用缺省(ask);
|
|
53
|
+
* - **首轮**(从未跑过)或**远端有新快照**:即使显式选了 'local' 也强制降级 ask(forcedAsk)——
|
|
54
|
+
* 这两个时刻正是「远端最新改动只在历史快照里」的事故现场,静默覆盖等于替用户丢数据;
|
|
55
|
+
* 降级记 forced,界面才能说清「这次没按你的 local 走」;
|
|
56
|
+
* - 只有「显式 local + 非高危时刻 + 真有冲突」才 force。
|
|
57
|
+
*
|
|
58
|
+
* 纯函数(无状态、无 I/O):调度层与单测共用同一份判定,避免两处各写一遍阈值。
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveConflictPushPolicy(args: {
|
|
61
|
+
configuredPolicy?: AutosyncConflictPolicy;
|
|
62
|
+
firstRun: boolean;
|
|
63
|
+
remoteNew: boolean;
|
|
64
|
+
conflicted: number;
|
|
65
|
+
}): {
|
|
66
|
+
policy: AutosyncConflictPolicy;
|
|
67
|
+
force: boolean;
|
|
68
|
+
forcedAsk: boolean;
|
|
69
|
+
};
|
|
36
70
|
/**
|
|
37
71
|
* runOnce 的触发语义(只影响「做什么」的两个分支,不改变同步流程本身)。
|
|
38
72
|
* reason 只进日志,用来说明这一轮是被谁叫醒的(排障时能区分「定时兜底」与「真有事发生」)。
|
|
@@ -67,7 +101,11 @@ export interface AutoSyncSchedulerOptions {
|
|
|
67
101
|
readConfig?: (channel: SyncTransportType) => Promise<AutosyncConfig>;
|
|
68
102
|
writeConfig?: (channel: SyncTransportType, cfg: AutosyncConfig) => Promise<void>;
|
|
69
103
|
/** 注入 sync-config 读取(按通道) */
|
|
70
|
-
|
|
104
|
+
/**
|
|
105
|
+
* SEC-03:可选第二参收集「读侧被丢弃的配置成因」。既有注入实现(单参闭包)依然合法 ——
|
|
106
|
+
* 少一个参数不是类型错误,缺省接线才负责真正回填。
|
|
107
|
+
*/
|
|
108
|
+
readSyncConfigFn?: (channel: SyncTransportType, issues?: SyncConfigIssue[]) => Promise<SyncConfig | null>;
|
|
71
109
|
/** 注入 sync-history 读写 */
|
|
72
110
|
readHistoryFn?: () => Promise<Awaited<ReturnType<typeof readSyncHistory>>>;
|
|
73
111
|
appendHistoryFn?: (entry: AutosyncHistoryEntry) => Promise<void>;
|
|
@@ -164,11 +202,25 @@ export declare class AutoSyncScheduler {
|
|
|
164
202
|
private readonly profile;
|
|
165
203
|
private readonly makeWatcher;
|
|
166
204
|
private readonly minTriggerGapMs;
|
|
205
|
+
/** SEC-03:已 warn 过的「地址被拒」成因(键 = 通道 + 成因文本),进程内去重。 */
|
|
206
|
+
private readonly reportedConfigIssues;
|
|
167
207
|
private stopped;
|
|
168
208
|
private running;
|
|
169
209
|
constructor(opts: AutoSyncSchedulerOptions);
|
|
170
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* 追加一条自动同步历史(自动带上触发通道;0x 安全:绝不覆盖调用方显式 transport)。
|
|
212
|
+
*
|
|
213
|
+
* SYN-05:额外允许 forced(本轮 ask 是被强制的)。该字段不在 sync-history.ts 的
|
|
214
|
+
* AutosyncHistoryEntry 里(那是另一个模块的公开契约,本轮不扩),但读写两侧都按普通对象
|
|
215
|
+
* 原样透传(readSyncHistory 逐条 cast),所以运行时可稳定往返;界面按「字段有无」渲染。
|
|
216
|
+
*/
|
|
171
217
|
private appendHistory;
|
|
218
|
+
/**
|
|
219
|
+
* SEC-03:「地址被拒」是持久状态,而自动同步每 interval(缺省 60s)就转一圈 ——
|
|
220
|
+
* 每轮打一条日志等于把日志刷成噪音。同一 (通道 + 成因) 只 warn 一次。
|
|
221
|
+
* 用户改了配置后成因文本会变(message 带真实地址/原因),自然再报一次。
|
|
222
|
+
*/
|
|
223
|
+
private reportConfigIssue;
|
|
172
224
|
/** 启动:读两个通道配置 → enabled 通道各自排定时器 → 对每个 enabled 通道执行一次启动触发。 */
|
|
173
225
|
start(): void;
|
|
174
226
|
/** 停止:清全部定时器、拆掉文件监听、标记不再调度;正在执行的任务允许自然结束。 */
|
|
@@ -247,3 +299,9 @@ export declare function buildAutoApplyPlan(plan: MergePlan): SyncApplyPlan;
|
|
|
247
299
|
* 未配置(null / 缺字段)→ false。作为 autosync 未配置跳过的判定依据。
|
|
248
300
|
*/
|
|
249
301
|
export declare function syncIsConfigured(cfg: SyncConfig | null): boolean;
|
|
302
|
+
/**
|
|
303
|
+
* SEC-03:从读侧收集到的 issue 里挑出本通道的成因(`code(channel): message`),
|
|
304
|
+
* 没有就返回 undefined —— 「真的没配」绝不伪造原因(无幻影告警)。
|
|
305
|
+
* 多通道并存时只取本通道,git 的拒因不该挂在 webdav 的历史里。
|
|
306
|
+
*/
|
|
307
|
+
export declare function describeConfigIssue(issues: readonly SyncConfigIssue[], channel: SyncTransportType): string | undefined;
|
|
@@ -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
|
*
|
|
@@ -76,6 +78,25 @@ export function shouldTriggerStartupRun(lastRunAt, startupMinIntervalMs, nowMs =
|
|
|
76
78
|
return true;
|
|
77
79
|
return nowMs - lastMs >= startupMinIntervalMs;
|
|
78
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* SYN-05:本轮冲突(下行冲突)该不该 force push —— 唯一判定点。
|
|
83
|
+
*
|
|
84
|
+
* 规则(缺省 ask 之上再加两道高危闸):
|
|
85
|
+
* - 用户没选过策略 → 用缺省(ask);
|
|
86
|
+
* - **首轮**(从未跑过)或**远端有新快照**:即使显式选了 'local' 也强制降级 ask(forcedAsk)——
|
|
87
|
+
* 这两个时刻正是「远端最新改动只在历史快照里」的事故现场,静默覆盖等于替用户丢数据;
|
|
88
|
+
* 降级记 forced,界面才能说清「这次没按你的 local 走」;
|
|
89
|
+
* - 只有「显式 local + 非高危时刻 + 真有冲突」才 force。
|
|
90
|
+
*
|
|
91
|
+
* 纯函数(无状态、无 I/O):调度层与单测共用同一份判定,避免两处各写一遍阈值。
|
|
92
|
+
*/
|
|
93
|
+
export function resolveConflictPushPolicy(args) {
|
|
94
|
+
const configured = args.configuredPolicy ?? DEFAULT_AUTOSYNC_CONFLICT_POLICY;
|
|
95
|
+
// forcedAsk 只描述「用户选了 local,但本轮被降级」——缺省 ask 不算被强制(那不是用户的决定被推翻)。
|
|
96
|
+
const forcedAsk = configured === 'local' && (args.firstRun || args.remoteNew);
|
|
97
|
+
const policy = forcedAsk ? 'ask' : configured;
|
|
98
|
+
return { policy, force: policy === 'local' && args.conflicted > 0, forcedAsk };
|
|
99
|
+
}
|
|
79
100
|
export class AutoSyncScheduler {
|
|
80
101
|
syncDir;
|
|
81
102
|
host;
|
|
@@ -118,6 +139,8 @@ export class AutoSyncScheduler {
|
|
|
118
139
|
profile;
|
|
119
140
|
makeWatcher;
|
|
120
141
|
minTriggerGapMs;
|
|
142
|
+
/** SEC-03:已 warn 过的「地址被拒」成因(键 = 通道 + 成因文本),进程内去重。 */
|
|
143
|
+
reportedConfigIssues = new Set();
|
|
121
144
|
stopped = false;
|
|
122
145
|
running = false;
|
|
123
146
|
constructor(opts) {
|
|
@@ -128,7 +151,7 @@ export class AutoSyncScheduler {
|
|
|
128
151
|
this.now = opts.now ?? (() => new Date());
|
|
129
152
|
this.readConfig = opts.readConfig ?? ((channel) => readAutosyncConfig(this.syncDir, channel));
|
|
130
153
|
this.writeConfig = opts.writeConfig ?? ((channel, cfg) => writeAutosyncConfig(this.syncDir, channel, cfg));
|
|
131
|
-
this.readSyncConfigFn = opts.readSyncConfigFn ?? ((channel) => readSyncConfigFor(this.syncDir, channel));
|
|
154
|
+
this.readSyncConfigFn = opts.readSyncConfigFn ?? ((channel, issues) => readSyncConfigFor(this.syncDir, channel, issues));
|
|
132
155
|
this.readHistoryFn = opts.readHistoryFn ?? (() => readSyncHistory(this.syncDir));
|
|
133
156
|
this.appendHistoryFn = opts.appendHistoryFn ?? ((entry) => appendAutosyncEntry(this.syncDir, entry));
|
|
134
157
|
this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
@@ -143,10 +166,28 @@ export class AutoSyncScheduler {
|
|
|
143
166
|
this.makeWatcher = opts.makeWatcher ?? defaultMakeWatcher;
|
|
144
167
|
this.minTriggerGapMs = opts.minTriggerGapMs ?? 3000;
|
|
145
168
|
}
|
|
146
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* 追加一条自动同步历史(自动带上触发通道;0x 安全:绝不覆盖调用方显式 transport)。
|
|
171
|
+
*
|
|
172
|
+
* SYN-05:额外允许 forced(本轮 ask 是被强制的)。该字段不在 sync-history.ts 的
|
|
173
|
+
* AutosyncHistoryEntry 里(那是另一个模块的公开契约,本轮不扩),但读写两侧都按普通对象
|
|
174
|
+
* 原样透传(readSyncHistory 逐条 cast),所以运行时可稳定往返;界面按「字段有无」渲染。
|
|
175
|
+
*/
|
|
147
176
|
async appendHistory(channel, entry) {
|
|
148
177
|
await this.appendHistoryFn({ ...entry, transport: channel });
|
|
149
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* SEC-03:「地址被拒」是持久状态,而自动同步每 interval(缺省 60s)就转一圈 ——
|
|
181
|
+
* 每轮打一条日志等于把日志刷成噪音。同一 (通道 + 成因) 只 warn 一次。
|
|
182
|
+
* 用户改了配置后成因文本会变(message 带真实地址/原因),自然再报一次。
|
|
183
|
+
*/
|
|
184
|
+
reportConfigIssue(channel, detail) {
|
|
185
|
+
const key = channel + '/' + detail;
|
|
186
|
+
if (this.reportedConfigIssues.has(key))
|
|
187
|
+
return;
|
|
188
|
+
this.reportedConfigIssues.add(key);
|
|
189
|
+
this.host.log.warn(`自动同步已跳过(${channel}):同步通道配置被安全校验拒绝,请重新填写地址(${detail})`);
|
|
190
|
+
}
|
|
150
191
|
/** 启动:读两个通道配置 → enabled 通道各自排定时器 → 对每个 enabled 通道执行一次启动触发。 */
|
|
151
192
|
start() {
|
|
152
193
|
if (this.stopped)
|
|
@@ -448,11 +489,18 @@ export class AutoSyncScheduler {
|
|
|
448
489
|
}
|
|
449
490
|
try {
|
|
450
491
|
// 读该通道 sync-config:按通道判定「已配置」——git 看 git.repoUrl、webdav 看 webdav.url。
|
|
451
|
-
|
|
492
|
+
// SEC-03:「未配置」有两种成因 —— 真的没配,或存过的地址被白名单拒掉(读侧丢弃)。
|
|
493
|
+
// 必须区分:后者如果说成前者的「尚未配置」,用户会一遍遍重填同一个被拒地址。
|
|
494
|
+
const configIssues = [];
|
|
495
|
+
const syncCfg = await this.readSyncConfigFn(channel, configIssues);
|
|
452
496
|
if (!syncIsConfigured(syncCfg)) {
|
|
497
|
+
const configIssue = describeConfigIssue(configIssues, channel);
|
|
498
|
+
if (configIssue !== undefined)
|
|
499
|
+
this.reportConfigIssue(channel, configIssue);
|
|
453
500
|
const result = {
|
|
454
501
|
status: 'skipped', direction: 'none', skipReason: 'unconfigured', historyId,
|
|
455
502
|
consecutiveFailures: 0,
|
|
503
|
+
...(configIssue === undefined ? {} : { configIssue }),
|
|
456
504
|
};
|
|
457
505
|
await this.appendHistory(channel, {
|
|
458
506
|
direction: 'both',
|
|
@@ -460,6 +508,7 @@ export class AutoSyncScheduler {
|
|
|
460
508
|
skipReason: 'unconfigured',
|
|
461
509
|
createdAt: nowIso,
|
|
462
510
|
failureCountAtRun: 0,
|
|
511
|
+
...(configIssue === undefined ? {} : { configIssue }),
|
|
463
512
|
});
|
|
464
513
|
await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
|
|
465
514
|
return result;
|
|
@@ -502,6 +551,9 @@ export class AutoSyncScheduler {
|
|
|
502
551
|
// 列表拿不到(网络失败)时 remoteMetas 为 null → 检测自己再 list 一次,行为与改造前一致
|
|
503
552
|
const remoteNew = await this.detectRemoteNew(engine, remoteMetas ?? undefined);
|
|
504
553
|
const localDirty = await this.detectLocalChange(engine);
|
|
554
|
+
// SYN-05:首轮(从未跑过)与「远端有新快照」是覆盖风险最高的两个时刻 —— 由
|
|
555
|
+
// resolveConflictPushPolicy 统一把这两处的 ask 标成 forced。
|
|
556
|
+
const firstRun = cfg.lastRunAt === undefined || cfg.lastRunAt === '';
|
|
505
557
|
// 两端都无变化 → 什么都不做,记为 upToDate(不重复拉取/不空转)。
|
|
506
558
|
if (!remoteNew && !localDirty) {
|
|
507
559
|
const result = {
|
|
@@ -603,13 +655,23 @@ export class AutoSyncScheduler {
|
|
|
603
655
|
if (!opts.startup && localDirty) {
|
|
604
656
|
try {
|
|
605
657
|
/**
|
|
606
|
-
* 冲突策略(conflictPolicy,缺省 '
|
|
658
|
+
* 冲突策略(conflictPolicy,缺省 'ask'):
|
|
607
659
|
* - 'ask':不强制,推送会被引擎的「远端已推进」前置检测挡下 → skipped/partial(conflict);
|
|
608
|
-
* - 'local'
|
|
609
|
-
* force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
|
|
660
|
+
* - 'local':用户显式选中才生效 —— **不写本地**(下行被冲突挡下),但把本机版本强制推成
|
|
661
|
+
* 新的远端最新。force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
|
|
610
662
|
* 被覆盖的远端旧版本留在远端历史快照里,可手动回滚。
|
|
663
|
+
*
|
|
664
|
+
* SYN-05:首轮 / 远端有新快照时即使显式选了 local 也强制降级 ask(forced=true 进结果与
|
|
665
|
+
* 历史),绝不在这两个高危时刻替用户覆盖远端最新。
|
|
611
666
|
*/
|
|
612
|
-
const
|
|
667
|
+
const pushDecision = resolveConflictPushPolicy({
|
|
668
|
+
...(cfg.conflictPolicy !== undefined ? { configuredPolicy: cfg.conflictPolicy } : {}),
|
|
669
|
+
firstRun,
|
|
670
|
+
remoteNew,
|
|
671
|
+
conflicted: conflictedSections.length,
|
|
672
|
+
});
|
|
673
|
+
const forcePush = pushDecision.force;
|
|
674
|
+
const forcedMark = pushDecision.forcedAsk ? { forced: true } : {};
|
|
613
675
|
// Phase C: 完整双向 → engine.push() 上传。P0-A:外部 push 包 intent journal。
|
|
614
676
|
const rawPush = async () => engine.push(forcePush ? { force: true } : {});
|
|
615
677
|
const pushReport = (this.phase3Recovery !== undefined && this.lockCtxForJournal !== null)
|
|
@@ -630,6 +692,7 @@ export class AutoSyncScheduler {
|
|
|
630
692
|
appliedSections,
|
|
631
693
|
...(conflictedSections.length > 0 ? { conflictedSections } : {}),
|
|
632
694
|
consecutiveFailures: 0,
|
|
695
|
+
...forcedMark,
|
|
633
696
|
};
|
|
634
697
|
await this.appendHistory(channel, {
|
|
635
698
|
direction: appliedSections.length ? 'both' : 'push', status: conflictResult.status,
|
|
@@ -637,6 +700,7 @@ export class AutoSyncScheduler {
|
|
|
637
700
|
appliedSections,
|
|
638
701
|
...(conflictedSections.length > 0 ? { conflictedSections } : {}),
|
|
639
702
|
createdAt: nowIso, failureCountAtRun: 0,
|
|
703
|
+
...forcedMark,
|
|
640
704
|
});
|
|
641
705
|
await this.writeFinalConfig(cfg, conflictResult, nowIso, historyId, channel);
|
|
642
706
|
return conflictResult;
|
|
@@ -680,6 +744,7 @@ export class AutoSyncScheduler {
|
|
|
680
744
|
...(skipReason !== undefined ? { skipReason } : {}),
|
|
681
745
|
...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
|
|
682
746
|
consecutiveFailures: 0,
|
|
747
|
+
...forcedMark,
|
|
683
748
|
};
|
|
684
749
|
await this.appendHistory(channel, {
|
|
685
750
|
direction: appliedSections.length ? 'both' : 'push',
|
|
@@ -688,6 +753,7 @@ export class AutoSyncScheduler {
|
|
|
688
753
|
...(skipReason !== undefined ? { skipReason } : {}),
|
|
689
754
|
...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
|
|
690
755
|
createdAt: nowIso, failureCountAtRun: 0,
|
|
756
|
+
...forcedMark,
|
|
691
757
|
});
|
|
692
758
|
await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
|
|
693
759
|
return result;
|
|
@@ -856,6 +922,17 @@ export function syncIsConfigured(cfg) {
|
|
|
856
922
|
}
|
|
857
923
|
return false;
|
|
858
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* SEC-03:从读侧收集到的 issue 里挑出本通道的成因(`code(channel): message`),
|
|
927
|
+
* 没有就返回 undefined —— 「真的没配」绝不伪造原因(无幻影告警)。
|
|
928
|
+
* 多通道并存时只取本通道,git 的拒因不该挂在 webdav 的历史里。
|
|
929
|
+
*/
|
|
930
|
+
export function describeConfigIssue(issues, channel) {
|
|
931
|
+
const own = issues.filter((issue) => issue.channel === channel);
|
|
932
|
+
if (own.length === 0)
|
|
933
|
+
return undefined;
|
|
934
|
+
return own.map((issue) => `${issue.code} (${issue.channel}): ${issue.message}`).join('; ');
|
|
935
|
+
}
|
|
859
936
|
/**
|
|
860
937
|
* 缺省远端新快照检测(§3.2):engine 实现了 hasNewRemoteSnapshot() → 调用;
|
|
861
938
|
* 否则(测试 mock 未实现)保守返回 true(假设有新生,保持旧行为=每次都尝试拉取)。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SyncConfig, SyncConfigIssue } from './sync-config.ts';
|
|
2
|
+
/**
|
|
3
|
+
* 造一个「配置被拒」报告器:返回的函数幂等 —— 同一 (通道/码/文案) 只输出一行。
|
|
4
|
+
*
|
|
5
|
+
* 去重理由:调用方每次同步请求都跑一遍,按请求打日志会被零信息量的重复行刷满;而
|
|
6
|
+
* 「地址被拒」是**持久状态**,报一次即可。键里带 message 而不只带 code:不同被拒地址的
|
|
7
|
+
* 成因文案不同,那是新的有用信息,用户改了配置再被拒应当再报一次。
|
|
8
|
+
*
|
|
9
|
+
* `log` 由调用方注入(宿主传 `host.log.warn`),本模块不碰日志设施与 i18n 字典。
|
|
10
|
+
*/
|
|
11
|
+
export declare function createSyncConfigIssueReporter(log: (line: string) => void): (issues: readonly SyncConfigIssue[]) => void;
|
|
12
|
+
/** `createPrepareSync` 的依赖(全部注入,便于单测与避免与 index.ts 形成循环依赖)。 */
|
|
13
|
+
export interface PrepareSyncOptions {
|
|
14
|
+
/** 同步配置目录(`sync-config.json` 所在处)。 */
|
|
15
|
+
syncDir: string;
|
|
16
|
+
/** 解析请求体(宿主传入带 credentials 的 closure)。 */
|
|
17
|
+
parseBody: (body: Record<string, unknown>) => Promise<SyncConfig>;
|
|
18
|
+
/** 把已保存的 webdav username 回填进请求体(宿主传入 `mergePersistedWebDavUsername`)。 */
|
|
19
|
+
mergeWebDavUsername: (cfg: SyncConfig, persisted: SyncConfig | null) => SyncConfig;
|
|
20
|
+
/** 告警出口(宿主传 `(line) => host.log.warn(line)`)。 */
|
|
21
|
+
log: (line: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 造 `prepareSync`:解析请求体,webdav 请求体未带 username 时从持久化配置回填 ——
|
|
25
|
+
* 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
|
|
26
|
+
* 语义与 password 一致:留空 = 沿用已保存凭据。
|
|
27
|
+
*
|
|
28
|
+
* SEC-03:顺带把「已落盘但过不了白名单、被读取侧丢弃」的 repoUrl 成因报一次;界面侧的
|
|
29
|
+
* 权威出口是 GET /sync/status 的 `configIssues`(字段恒在、按请求实时算),这里只补日志。
|
|
30
|
+
* 读配置失败时保持原值(空 username 由 WebDavTransport 构造校验兜底报错)。
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPrepareSync(opts: PrepareSyncOptions): (body: Record<string, unknown>) => Promise<SyncConfig>;
|
|
@@ -0,0 +1,63 @@
|
|
|
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.js';
|
|
14
|
+
/**
|
|
15
|
+
* 造一个「配置被拒」报告器:返回的函数幂等 —— 同一 (通道/码/文案) 只输出一行。
|
|
16
|
+
*
|
|
17
|
+
* 去重理由:调用方每次同步请求都跑一遍,按请求打日志会被零信息量的重复行刷满;而
|
|
18
|
+
* 「地址被拒」是**持久状态**,报一次即可。键里带 message 而不只带 code:不同被拒地址的
|
|
19
|
+
* 成因文案不同,那是新的有用信息,用户改了配置再被拒应当再报一次。
|
|
20
|
+
*
|
|
21
|
+
* `log` 由调用方注入(宿主传 `host.log.warn`),本模块不碰日志设施与 i18n 字典。
|
|
22
|
+
*/
|
|
23
|
+
export function createSyncConfigIssueReporter(log) {
|
|
24
|
+
const reported = new Set();
|
|
25
|
+
return (issues) => {
|
|
26
|
+
for (const issue of issues) {
|
|
27
|
+
const key = issue.channel + '/' + issue.code + '/' + issue.message;
|
|
28
|
+
if (reported.has(key))
|
|
29
|
+
continue;
|
|
30
|
+
reported.add(key);
|
|
31
|
+
log('sync config rejected (' + issue.channel + '/' + issue.code + '): ' + issue.message);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 造 `prepareSync`:解析请求体,webdav 请求体未带 username 时从持久化配置回填 ——
|
|
37
|
+
* 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
|
|
38
|
+
* 语义与 password 一致:留空 = 沿用已保存凭据。
|
|
39
|
+
*
|
|
40
|
+
* SEC-03:顺带把「已落盘但过不了白名单、被读取侧丢弃」的 repoUrl 成因报一次;界面侧的
|
|
41
|
+
* 权威出口是 GET /sync/status 的 `configIssues`(字段恒在、按请求实时算),这里只补日志。
|
|
42
|
+
* 读配置失败时保持原值(空 username 由 WebDavTransport 构造校验兜底报错)。
|
|
43
|
+
*/
|
|
44
|
+
export function createPrepareSync(opts) {
|
|
45
|
+
const reportIssues = createSyncConfigIssueReporter(opts.log);
|
|
46
|
+
return async (body) => {
|
|
47
|
+
const cfg = await opts.parseBody(body);
|
|
48
|
+
if (!isWebDavConfig(cfg))
|
|
49
|
+
return cfg;
|
|
50
|
+
if (cfg.webdav.username !== undefined && cfg.webdav.username !== '')
|
|
51
|
+
return cfg;
|
|
52
|
+
try {
|
|
53
|
+
const issues = [];
|
|
54
|
+
const persisted = await readSyncConfig(opts.syncDir, issues);
|
|
55
|
+
reportIssues(issues);
|
|
56
|
+
return opts.mergeWebDavUsername(cfg, persisted);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return cfg;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=config-issue-reporting.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MsgFunc } from '../core/messages.ts';
|
|
2
|
+
/**
|
|
3
|
+
* 本轮 blob GC 的放弃码(SYN-04)。**机器可判定**,文案由上层按码翻译(字典键由 Lead 统一加):
|
|
4
|
+
* 通道没有 logger,`delete()` 还会吞掉 GC 抛出的异常,所以「本轮为什么没回收」只能靠这个码如实外露。
|
|
5
|
+
* 三态同义:拿不到引用者内容 = 无法证明「没人引用」⇒ 本轮整轮放弃,绝不按残集删除。
|
|
6
|
+
*
|
|
7
|
+
* 单一来源:webdav / s3 两个通道都从这里导入(此前各写一份同名常量,改一侧不炸另一侧 =
|
|
8
|
+
* 静默漂移;本模块不 import 任何 transport,所以不存在环)。
|
|
9
|
+
*/
|
|
10
|
+
export declare const GC_SKIP_REFERENCE_MISSING = "gc-skip-reference-missing";
|
|
11
|
+
export declare const GC_SKIP_REFERENCE_UNREADABLE = "gc-skip-reference-unreadable";
|
|
12
|
+
export declare const GC_SKIP_SNAPSHOT_UNPARSABLE = "gc-skip-snapshot-unparsable";
|
|
13
|
+
/** blob GC 放弃码(SYN-04)。 */
|
|
14
|
+
export type GcSkipCode = typeof GC_SKIP_REFERENCE_MISSING | typeof GC_SKIP_REFERENCE_UNREADABLE | typeof GC_SKIP_SNAPSHOT_UNPARSABLE;
|
|
15
|
+
/**
|
|
16
|
+
* SYN-04b:通道「本轮 GC 放弃码」的读取与冒泡(从 sync-engine.ts 下沉,行为逐字不变)。
|
|
17
|
+
*
|
|
18
|
+
* 为什么放在独立模块:SYN-04 之后 webdav/s3 遇到「引用快照 404 / 读不出」会**放弃本轮 blob GC**
|
|
19
|
+
* (绝不在「证明不了没人引用」时删 blob),而通道层没有 logger、delete() 又把 GC 异常吃掉
|
|
20
|
+
* ⇒ 放弃只能靠 transport 的只读字段外露。字段形态是 getter(见 webdav/s3 transport 的
|
|
21
|
+
* `get lastGcSkip()`),这里按 unknown 读、只接受非空字符串,getter 抛错也不影响 push。
|
|
22
|
+
*
|
|
23
|
+
* 判据(缺一不可,全部当「没有放弃码」):字段缺失 / null / 非字符串 / 空串 / getter 抛错。
|
|
24
|
+
* 读不到 ≠ 没有放弃:通道没这个能力时本来就不该冒泡告警,所以两者在调用侧合并处理。
|
|
25
|
+
*/
|
|
26
|
+
export declare function readGcSkipCode(transport: unknown): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* SYN-04b:放弃码 → 一条 warning 文本。
|
|
29
|
+
*
|
|
30
|
+
* 码必须同时以**明文**出现:core 目录的翻译器对未知键回退键名本身({code} 插值随之丢失),
|
|
31
|
+
* 而文案键由上层统一补字典 —— 在补键之前,只发出裸键名等于「报了但看不出来」,日志也无法 grep。
|
|
32
|
+
*/
|
|
33
|
+
export declare function gcSkipWarning(msg: MsgFunc, code: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* SYN-04b:把通道的 GC 放弃码冒泡成一条 warning(`state` 保证同一轮只报一次)。
|
|
36
|
+
*
|
|
37
|
+
* 语义边界:放弃 GC **不是** push 失败 —— 快照本身已上传/已删除,只是这一轮的孤儿 blob
|
|
38
|
+
* 没被回收(磁盘/远端会多留几份文件)。所以它进 warnings、不进 ok,与 prune 既有语义一致。
|
|
39
|
+
*/
|
|
40
|
+
export declare function reportGcSkipOnce(transport: unknown, msg: MsgFunc, warnings: string[], state: {
|
|
41
|
+
done: boolean;
|
|
42
|
+
}): void;
|