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
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* 危险键(防 prototype pollution);解析失败抛错 → 导入方归为「Backup integrity check failed」。
|
|
11
11
|
* - 一步到位 `verifyChecksumsJson`:解析 + 校验合并,供 m5/导入管线直接调用。
|
|
12
12
|
* - 校验语义:表内每个路径必须在条目集合中存在且 SHA-256 一致;缺失与不符均计入 mismatches。
|
|
13
|
+
* - 反向覆盖(SEC-05):`unverifiedEntries` 算出「ZIP 有、表里没登记」的条目 —— 正向校验看不到
|
|
14
|
+
* 它们,而文件类分区按归档名原样落盘,缺了这条判定就等于「完整性只覆盖一半」。
|
|
13
15
|
*/
|
|
14
16
|
import { parseJsonSafe } from '../utils/json.ts';
|
|
15
17
|
import {
|
|
@@ -92,6 +94,41 @@ export function verifyChecksumsJson(
|
|
|
92
94
|
return verifyChecksums(entries, table);
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 会落盘的文件分区条目「未登记进校验表」时的错误码(写进 `PlanItem.detail`,也进执行报告)。
|
|
99
|
+
*
|
|
100
|
+
* 语义不是「哈希不符」(那由 `import.integrityFailed` 表示,且直接失败),而是
|
|
101
|
+
* 「这个条目根本没被校验表覆盖」—— 完整性声明对它零约束(SEC-05)。
|
|
102
|
+
*/
|
|
103
|
+
export const UNVERIFIED_FILE_ENTRY_CODE = 'unverified-file-entry';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 上述状态的文案键。字典条目由维护方补齐;缺失时 messages 回退成键名本身、绝不抛错,
|
|
107
|
+
* 所以导入流程不会因为缺一条文案而中断。
|
|
108
|
+
*/
|
|
109
|
+
export const UNVERIFIED_FILE_ENTRY_KEY = 'import.unverifiedFileEntry';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 校验表的**反向覆盖**:entries 里存在、校验表里没登记的路径。
|
|
113
|
+
*
|
|
114
|
+
* `verifyChecksums` 只正向遍历表里的键(登记的必须存在且 SHA-256 一致),**表外条目它不看**;
|
|
115
|
+
* 而文件类分区是按归档名原样落盘的(`adapters/file-collection.ts` 的 applyItem),
|
|
116
|
+
* 于是「ZIP 里有、checksums 没登记」的文件照样写进目标目录 —— 完整性声明只约束了登记过的那一半。
|
|
117
|
+
*
|
|
118
|
+
* 本函数是「谁没被覆盖」的**单一事实源**:loadBundle 的告警与导入侧的阻断判定共用它,
|
|
119
|
+
* 不各算一遍(两份实现迟早分叉)。判据与既有告警逐字一致:
|
|
120
|
+
* 跳过目录条目(以 `/` 结尾)与调用方给出的结构文件(manifest.json / integrity/checksums.json)。
|
|
121
|
+
*/
|
|
122
|
+
export function unverifiedEntries(
|
|
123
|
+
names: readonly string[],
|
|
124
|
+
table: Record<string, string>,
|
|
125
|
+
exclude: readonly string[] = [],
|
|
126
|
+
): string[] {
|
|
127
|
+
const skip = new Set(exclude);
|
|
128
|
+
const covered = new Set(Object.keys(table));
|
|
129
|
+
return names.filter((name) => !name.endsWith('/') && !skip.has(name) && !covered.has(name));
|
|
130
|
+
}
|
|
131
|
+
|
|
95
132
|
/** 人类可读的不匹配摘要(给「Backup integrity check failed」错误消息用) */
|
|
96
133
|
export function describeMismatches(result: ChecksumVerifyResult): string {
|
|
97
134
|
const parts = [
|
|
@@ -0,0 +1,192 @@
|
|
|
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.ts';
|
|
15
|
+
import { CHECKSUMS_FILE, MANIFEST_FILE } from '../schema/manifest.ts';
|
|
16
|
+
import { parseJsonSafe } from '../utils/json.ts';
|
|
17
|
+
import { verifyChecksums } from '../utils/hashing.ts';
|
|
18
|
+
import type { ChecksumVerifyResult } from '../utils/hashing.ts';
|
|
19
|
+
import { UNVERIFIED_FILE_ENTRY_CODE, UNVERIFIED_FILE_ENTRY_KEY, unverifiedEntries } from './integrity.ts';
|
|
20
|
+
import type { SectionId } from '../schema/types.ts';
|
|
21
|
+
import type { MsgFunc } from '../core/msg-types.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 本模块需要的计划项字段(结构子集)。刻意不 import `core/types.ts` 的 `PlanItem`:
|
|
25
|
+
* 值依赖方向是 core → security,security 只描述契约。
|
|
26
|
+
*/
|
|
27
|
+
export interface UnverifiedBlockableItem {
|
|
28
|
+
adapter: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
severity?: string;
|
|
31
|
+
detail?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
target?: { ref?: string } | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* SEC-05 文案:优先用专属键 `import.unverifiedFileEntry`。
|
|
38
|
+
*
|
|
39
|
+
* 字典还没收录该键时 `msg` 会回退成键名本身(messages.ts 的「未知键绝不抛错」约定),
|
|
40
|
+
* 那样文案里只有键名、**连是哪个文件都没说** —— 对用户是最糟的形态。因此回退到既有的
|
|
41
|
+
* `import.extraEntries`(已翻译、也带条目名)。两段指向同一事实:「这个条目没被校验表覆盖」。
|
|
42
|
+
*/
|
|
43
|
+
/** 单条文案最多列几个条目 —— 校验表缺失时名单 = 整包文件分区条目,不截断会撑爆一条 warning。 */
|
|
44
|
+
const MAX_LISTED_ENTRIES = 10;
|
|
45
|
+
|
|
46
|
+
export function unverifiedFileEntryText(text: MsgFunc, names: readonly string[]): string {
|
|
47
|
+
return render(text, UNVERIFIED_FILE_ENTRY_KEY, names);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function render(text: MsgFunc, key: string, names: readonly string[]): string {
|
|
51
|
+
const head = names.slice(0, MAX_LISTED_ENTRIES).map((n) => `"${n}"`).join(', ');
|
|
52
|
+
const list = names.length > MAX_LISTED_ENTRIES ? `${head} …(+${names.length - MAX_LISTED_ENTRIES})` : head;
|
|
53
|
+
const rendered = text(key, { entries: list });
|
|
54
|
+
return rendered === key ? text('import.extraEntries', { entries: list }) : rendered;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 未覆盖条目的**唯一**一条告警(RV1-F11:同一事实只发一次,不再与 `import.extraEntries` 并存)。
|
|
59
|
+
*
|
|
60
|
+
* 键按「到底拦没拦」选,避免文案自称做了没做的事:
|
|
61
|
+
* - 真拦下了(blocked > 0)⇒ 专属键 `import.unverifiedFileEntry`(「已逐条阻断写入」);
|
|
62
|
+
* - 一个都没拦(豁免分区 / 不在任何文件分区前缀下的散落条目,守 INT-01)⇒ 中性口径 `import.extraEntries`。
|
|
63
|
+
*/
|
|
64
|
+
export function unverifiedNoticeText(text: MsgFunc, names: readonly string[], blocked: number): string {
|
|
65
|
+
return render(text, blocked > 0 ? UNVERIFIED_FILE_ENTRY_KEY : 'import.extraEntries', names);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 归档里的结构文件(不属于内容,永不参与「未覆盖」判定)。 */
|
|
69
|
+
const STRUCTURAL_FILES = [MANIFEST_FILE, CHECKSUMS_FILE];
|
|
70
|
+
|
|
71
|
+
/** 落在文件分区前缀下的归档条目(目录标记条目与结构文件排除)。 */
|
|
72
|
+
function fileSectionEntries(names: readonly string[]): string[] {
|
|
73
|
+
const skip = new Set(STRUCTURAL_FILES);
|
|
74
|
+
const prefixes = Object.values(SECTION_FILE_PREFIXES).filter((prefix) => prefix !== '');
|
|
75
|
+
return names.filter((n) => !n.endsWith('/') && !skip.has(n) && prefixes.some((p) => n.startsWith(p)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 校验表**覆盖不到**的条目(反向覆盖)。
|
|
80
|
+
*
|
|
81
|
+
* - 表存在且非空:表里没登记的条目(`unverifiedEntries` 的判据,正向校验看不到它们);
|
|
82
|
+
* - 表**缺失/为空**:一个条目都没被校验 ⇒ 文件分区前缀下的条目一律视为未覆盖。
|
|
83
|
+
* 这是 RV1-F1 抓到的绕过:删掉 `integrity/checksums.json`(或置 `{}`)就等于把 SEC-05 关掉。
|
|
84
|
+
* 口径选「逐条 fail-closed」而非整包拒:旧包/手工包的非文件分区(settings 等)照常导入,
|
|
85
|
+
* 被拦下的只是「按归档名原样落盘、完整性声明又完全管不到」的那部分。
|
|
86
|
+
*
|
|
87
|
+
* 文案不在这里出(RV1-F11):同一事实只由 `analyzeImport` 发一条 `import.unverifiedFileEntry`。
|
|
88
|
+
*/
|
|
89
|
+
export function scanUnverifiedEntries(names: readonly string[], table: Record<string, string> | null): string[] {
|
|
90
|
+
if (table === null || Object.keys(table).length === 0) return fileSectionEntries(names);
|
|
91
|
+
return unverifiedEntries(names, table, STRUCTURAL_FILES);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 会写盘的计划项 kind(Skip 本来就不写目标,改成 Error 只会让无害 no-op 显示成失败)。 */
|
|
95
|
+
const WRITING_KINDS = new Set(['Create', 'Update', 'Conflict']);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 把命中「表外 + 会写盘」的文件分区计划项升级为 Error。
|
|
99
|
+
*
|
|
100
|
+
* 为什么必须拦:文件集合分区按归档名原样落盘(`adapters/file-collection.ts` 的 applyItem 只看
|
|
101
|
+
* `target.ref`),而 `verifyAgainstTable` 只正向校验表里登记的条目 —— 表外文件一路绿灯。
|
|
102
|
+
* 拦在执行之前、拦在计划项上(kind:'Error'):`applyOne` 遇到 Error 直接记 failed、绝不写目标,
|
|
103
|
+
* 用户也能在预览里看到「为什么这条没导」。
|
|
104
|
+
*
|
|
105
|
+
* @returns 被阻断的归档条目名(按计划项顺序,供调用方汇总一条提示)
|
|
106
|
+
*/
|
|
107
|
+
export function applyUnverifiedFileBlocks(
|
|
108
|
+
unverified: readonly string[],
|
|
109
|
+
items: UnverifiedBlockableItem[],
|
|
110
|
+
fileSections: ReadonlySet<string>,
|
|
111
|
+
exemptSections: readonly string[],
|
|
112
|
+
text: MsgFunc,
|
|
113
|
+
): string[] {
|
|
114
|
+
if (unverified.length === 0) return [];
|
|
115
|
+
const unverifiedSet = new Set(unverified);
|
|
116
|
+
const exempt = new Set<string>(exemptSections);
|
|
117
|
+
const blocked: string[] = [];
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
if (!fileSections.has(item.adapter) || exempt.has(item.adapter)) continue;
|
|
120
|
+
if (!WRITING_KINDS.has(item.kind)) continue;
|
|
121
|
+
const ref = item.target?.ref;
|
|
122
|
+
if (ref === undefined) continue;
|
|
123
|
+
const archiveName = (SECTION_FILE_PREFIXES[item.adapter as SectionId] ?? '') + ref;
|
|
124
|
+
if (!unverifiedSet.has(archiveName)) continue;
|
|
125
|
+
item.kind = 'Error';
|
|
126
|
+
item.severity = 'error';
|
|
127
|
+
item.detail = UNVERIFIED_FILE_ENTRY_CODE;
|
|
128
|
+
item.description = unverifiedFileEntryText(text, [archiveName]);
|
|
129
|
+
blocked.push(archiveName);
|
|
130
|
+
}
|
|
131
|
+
return blocked;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ZIP 归档读取子集(结构子集:`ZipArchive` 可赋值);本模块不 import core 的 ZIP 解析。 */
|
|
135
|
+
export interface IntegrityArchive {
|
|
136
|
+
has(name: string): boolean;
|
|
137
|
+
names(): string[];
|
|
138
|
+
readEntryText(name: string): string;
|
|
139
|
+
readEntryAsync(name: string): Promise<Uint8Array>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ArchiveIntegrityScan {
|
|
143
|
+
checksums: ChecksumVerifyResult;
|
|
144
|
+
/** 表外条目(反向覆盖);校验表缺失/为空时 = 文件分区前缀下的全部条目(见 scanUnverifiedEntries) */
|
|
145
|
+
unverified: string[];
|
|
146
|
+
/** 要挂进 zipWarnings 的文案(目前只有「校验表缺失」;反向覆盖文案由 analyzeImport 统一发一条) */
|
|
147
|
+
warnings: string[];
|
|
148
|
+
/** 完整性不符时的错误文案;undefined = 通过(调用方自己 throw,文案由注入的 msg 决定) */
|
|
149
|
+
failure?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 归档完整性扫描:正向(表内每条必须存在且 SHA-256 一致)+ 反向(ZIP 有、表里没登记)。
|
|
154
|
+
*
|
|
155
|
+
* 正向用 `utils/hashing.ts` 的 `verifyChecksums` —— 与 `import-planning.ts` 里的同名判据逐字等价,
|
|
156
|
+
* 是同一份语义的规范实现,此处不再复制第二遍。
|
|
157
|
+
* 判定却必须与导入阻断共用(`scanUnverifiedEntries` / `unverifiedEntries`),否则「告警说的表外条目」
|
|
158
|
+
* 与「真正被拦下的条目」会分叉。
|
|
159
|
+
*/
|
|
160
|
+
export async function scanArchiveIntegrity(archive: IntegrityArchive, text: MsgFunc): Promise<ArchiveIntegrityScan> {
|
|
161
|
+
const warnings: string[] = [];
|
|
162
|
+
const names = archive.names();
|
|
163
|
+
// 校验表缺失或为空:全部条目都处于「未登记 ⇒ 未被校验」,两种形态对用户是同一件事,共用一条告警。
|
|
164
|
+
// 此时 checksums 恒 ok(没有可正向校验的声明),防线全靠下一行 scanUnverifiedEntries 的 fail-closed。
|
|
165
|
+
const table = archive.has(CHECKSUMS_FILE)
|
|
166
|
+
? (parseJsonSafe(archive.readEntryText(CHECKSUMS_FILE)) as Record<string, string>)
|
|
167
|
+
: null;
|
|
168
|
+
if (table === null || Object.keys(table).length === 0) warnings.push(text('import.checksumsMissing'));
|
|
169
|
+
let checksums: ChecksumVerifyResult = { ok: true, mismatches: [], missing: [] };
|
|
170
|
+
if (table !== null && Object.keys(table).length > 0) {
|
|
171
|
+
const entries = new Map<string, Uint8Array>();
|
|
172
|
+
// 异步解压:这是全仓唯一逐条解压全部条目的地方(`inflateRawSync` 会整段阻塞事件循环)。
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
if (name === MANIFEST_FILE || name === CHECKSUMS_FILE) continue;
|
|
175
|
+
try {
|
|
176
|
+
entries.set(name, await archive.readEntryAsync(name));
|
|
177
|
+
} catch {
|
|
178
|
+
entries.delete(name); // 损坏条目在完整性阶段即失败(最终体现为 missing)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
checksums = verifyChecksums(entries, table);
|
|
182
|
+
}
|
|
183
|
+
// 反向覆盖只出条目名,文案统一由 analyzeImport 发一条(RV1-F11:同一事实不重复告警)。
|
|
184
|
+
const unverified = scanUnverifiedEntries(names, table);
|
|
185
|
+
const failure = checksums.ok
|
|
186
|
+
? undefined
|
|
187
|
+
: text('import.integrityFailed', {
|
|
188
|
+
entries: [...checksums.mismatches, ...checksums.missing].map((m) => `"${m}"`).join(', '),
|
|
189
|
+
});
|
|
190
|
+
return { checksums, unverified, warnings, ...(failure !== undefined ? { failure } : {}) };
|
|
191
|
+
}
|
|
192
|
+
|
|
@@ -54,17 +54,21 @@ export const DEFAULT_AUTOSYNC_AUTO_APPLY_TIER: AutosyncAutoApplyTier = 'medium';
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* 双侧都改(真冲突)时的自动裁决策略:
|
|
57
|
-
* - 'ask'
|
|
58
|
-
* - 'local'
|
|
59
|
-
*
|
|
57
|
+
* - 'ask' :缺省 —— 保留冲突待人工裁决,冲突期间**零写入**(下行不动、上行不 force);
|
|
58
|
+
* - 'local' :用户显式选中才生效 —— **保留本机并推上去**(VS Code 风格):本机版本成为新的
|
|
59
|
+
* 远端最新,被覆盖的远端版本仍留在远端历史快照里,随时可回滚。
|
|
60
|
+
*
|
|
61
|
+
* SYN-05 为什么把缺省从 'local' 改成 'ask':'local' 缺省会在「首轮」与「远端刚出现新快照」
|
|
62
|
+
* 这两个高危时刻静默用本机覆盖远端最新(用户根本没看见那段远端改动就被盖掉)。缺省必须选
|
|
63
|
+
* **安全且可逆**的一侧 = ask;想覆盖必须自己选,且首轮 / 远端有新快照时仍降级为 ask(forced)。
|
|
60
64
|
*
|
|
61
65
|
* 刻意**不提供** 'remote':采用远端需要走 applyMergeResolutions,那条路径会整体应用所有
|
|
62
66
|
* 可决断分区、绕过 risk.ts 的风险分级(audit-sync sync-F5),与「分区逐档放行」冲突。
|
|
63
67
|
*/
|
|
64
68
|
export type AutosyncConflictPolicy = 'ask' | 'local';
|
|
65
69
|
|
|
66
|
-
/** 缺省冲突策略:local
|
|
67
|
-
export const DEFAULT_AUTOSYNC_CONFLICT_POLICY: AutosyncConflictPolicy = '
|
|
70
|
+
/** 缺省冲突策略:ask(冲突待人工裁决;显式写过 local 的旧配置仍按 local 生效) */
|
|
71
|
+
export const DEFAULT_AUTOSYNC_CONFLICT_POLICY: AutosyncConflictPolicy = 'ask';
|
|
68
72
|
|
|
69
73
|
/** 最近一次自动同步执行状态 */
|
|
70
74
|
export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
|
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import test from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
10
13
|
|
|
11
14
|
import {
|
|
12
15
|
AutoSyncScheduler, intervalToMs, shouldTriggerStartupRun, buildAutoApplyPlan,
|
|
16
|
+
resolveConflictPushPolicy,
|
|
13
17
|
} from './autosync-scheduler.ts';
|
|
14
18
|
import { RunRegistry } from '../core/run-registry.ts';
|
|
15
19
|
import { nullLogger, createLogger } from '../utils/logger.ts';
|
|
@@ -22,6 +26,7 @@ import type { SyncSnapshotMeta } from './transport.ts';
|
|
|
22
26
|
import type { SectionId } from '../schema/types.ts';
|
|
23
27
|
import type { SyncEngine } from './sync-engine.ts';
|
|
24
28
|
import type { SyncConfig, SyncTransportType } from './sync-config.ts';
|
|
29
|
+
import { SYNC_CONFIG_FILE } from './sync-config.ts';
|
|
25
30
|
import { SYNC_CHANNELS } from './sync-config.ts';
|
|
26
31
|
import { syncIsConfigured } from './autosync-scheduler.ts';
|
|
27
32
|
|
|
@@ -351,7 +356,7 @@ test('runOnce: merge 抛错 → failed,连续失败计数 +1', async () => {
|
|
|
351
356
|
assert.ok(getEntries().some((e) => e.status === 'failed'), '写入失败历史');
|
|
352
357
|
});
|
|
353
358
|
|
|
354
|
-
test('
|
|
359
|
+
test('SYN-05:缺省策略 = ask —— 冲突只挡下行,绝不 force 覆盖远端(首轮同样强制 ask)', async () => {
|
|
355
360
|
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 1 };
|
|
356
361
|
const applyCalls: string[] = [];
|
|
357
362
|
const pushCalls: Array<{ force?: boolean } | undefined> = [];
|
|
@@ -370,20 +375,81 @@ test('issue #79 + conflictPolicy=local(缺省):冲突只挡下行,本机
|
|
|
370
375
|
};
|
|
371
376
|
const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
|
|
372
377
|
const result = await scheduler.runOnce('git');
|
|
373
|
-
assert.equal(result.status, 'partial', '上行成功 +
|
|
374
|
-
assert.equal(result.skipReason, 'conflict
|
|
378
|
+
assert.equal(result.status, 'partial', '上行成功 + 下行待裁决 → partial');
|
|
379
|
+
assert.equal(result.skipReason, 'conflict', '缺省绝不宣称「本机已覆盖远端」');
|
|
375
380
|
assert.deepEqual(result.conflictedSections, ['settings']);
|
|
376
381
|
assert.equal(result.pushedSnapshotId, 'snap-up', '本机快照仍有上行出口(issue #79 主诉)');
|
|
377
382
|
assert.equal(applyCalls.length, 0, '冲突绝不写本机配置');
|
|
378
383
|
assert.equal(pushCalls.length, 1, '纯上行不被冲突连坐');
|
|
379
|
-
assert.equal(pushCalls[0]?.force,
|
|
384
|
+
assert.equal(pushCalls[0]?.force, undefined, '缺省 ask 一律不得带 force(绝不覆盖远端最新)');
|
|
385
|
+
assert.equal(result.forced, undefined, '缺省就是 ask:不算「用户选择被推翻」,不标 forced');
|
|
380
386
|
assert.equal(result.consecutiveFailures, 0, '冲突跳过不是失败 → 计数归零');
|
|
381
|
-
assert.equal(getConfig().lastRunSkipReason, 'conflict
|
|
387
|
+
assert.equal(getConfig().lastRunSkipReason, 'conflict', '跳过原因落到状态(界面按码取字典)');
|
|
382
388
|
assert.equal(getConfig().lastRunSkipCount, 1);
|
|
383
|
-
assert.ok(getEntries().some((e) => e.status === 'partial' && e.skipReason === 'conflict
|
|
389
|
+
assert.ok(getEntries().some((e) => e.status === 'partial' && e.skipReason === 'conflict'), '写入 partial 历史');
|
|
390
|
+
|
|
384
391
|
assert.ok(getEntries().some((e) => e.pushedSnapshotId === 'snap-up'), '历史带上行快照 id');
|
|
385
392
|
});
|
|
386
393
|
|
|
394
|
+
test('SYN-05:显式 local + 首轮 → 降级 ask 且 forced=true(历史留痕)', async () => {
|
|
395
|
+
const cfg: AutosyncConfig = {
|
|
396
|
+
enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0,
|
|
397
|
+
conflictPolicy: 'local',
|
|
398
|
+
};
|
|
399
|
+
const pushCalls: Array<{ force?: boolean } | undefined> = [];
|
|
400
|
+
const engine = {
|
|
401
|
+
hasNewRemoteSnapshot: async () => true,
|
|
402
|
+
hasLocalChanges: async () => true,
|
|
403
|
+
merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'conflict']]),
|
|
404
|
+
push: async (opts?: { force?: boolean }) => {
|
|
405
|
+
pushCalls.push(opts);
|
|
406
|
+
return { ok: true, snapshotId: 'snap-up', sections: [] as never, warnings: [] };
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
const { scheduler, getEntries } = makeScheduler({ cfg, engine, history: [] });
|
|
410
|
+
const result = await scheduler.runOnce('git');
|
|
411
|
+
assert.equal(pushCalls[0]?.force, undefined, '首轮 = 高危时刻:显式 local 也不得 force');
|
|
412
|
+
assert.equal(result.skipReason, 'conflict');
|
|
413
|
+
assert.equal(result.forced, true, '用户选过 local 但被降级 → 必须标 forced');
|
|
414
|
+
const forcedEntries = getEntries().filter(
|
|
415
|
+
(e) => (e as AutosyncHistoryEntry & { forced?: boolean }).forced === true,
|
|
416
|
+
);
|
|
417
|
+
assert.equal(forcedEntries.length, 1, '历史同步留痕(界面能说清「这次没按你的设置走」)');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('SYN-05:远端有新快照(非首轮)+ 显式 local → 仍强制 ask,不覆盖远端最新', async () => {
|
|
421
|
+
const cfg: AutosyncConfig = {
|
|
422
|
+
enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0,
|
|
423
|
+
conflictPolicy: 'local', lastRunAt: '2026-11-01T10:00:00.000Z',
|
|
424
|
+
};
|
|
425
|
+
const pushCalls: Array<{ force?: boolean } | undefined> = [];
|
|
426
|
+
const engine = {
|
|
427
|
+
hasNewRemoteSnapshot: async () => true,
|
|
428
|
+
hasLocalChanges: async () => true,
|
|
429
|
+
merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'conflict']]),
|
|
430
|
+
push: async (opts?: { force?: boolean }) => {
|
|
431
|
+
pushCalls.push(opts);
|
|
432
|
+
return { ok: true, snapshotId: 'snap-up', sections: [] as never, warnings: [] };
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
const { scheduler } = makeScheduler({ cfg, engine, history: [] });
|
|
436
|
+
const result = await scheduler.runOnce('git');
|
|
437
|
+
assert.equal(pushCalls[0]?.force, undefined, '远端有新快照 = 高危时刻,即使用户选过 local 也不得 force');
|
|
438
|
+
assert.equal(result.skipReason, 'conflict');
|
|
439
|
+
assert.equal(result.forced, true, '强制降级必须可见');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('SYN-05:非首轮 + 远端无新快照 + 显式 local → 尊重用户选择(force 语义保留)', () => {
|
|
443
|
+
// 冲突只可能随「远端有新快照」出现,因此调度层实际上不会走到 force;
|
|
444
|
+
// 这条断言钉住判定函数本身:显式 local 且非高危时刻 → force 仍为 true(策略没被单方面删除)。
|
|
445
|
+
const decision = resolveConflictPushPolicy({
|
|
446
|
+
configuredPolicy: 'local', firstRun: false, remoteNew: false, conflicted: 1,
|
|
447
|
+
});
|
|
448
|
+
assert.equal(decision.policy, 'local');
|
|
449
|
+
assert.equal(decision.force, true);
|
|
450
|
+
assert.equal(decision.forcedAsk, false);
|
|
451
|
+
});
|
|
452
|
+
|
|
387
453
|
test('issue #79 + conflictPolicy=ask:冲突只挡下行,绝不强推覆盖远端(partial + skipped 码 conflict)', async () => {
|
|
388
454
|
const cfg: AutosyncConfig = {
|
|
389
455
|
enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 1,
|
|
@@ -994,3 +1060,64 @@ test('运行期多通道触发:每个通道各自补跑(单槽实现会让
|
|
|
994
1060
|
await tick();
|
|
995
1061
|
});
|
|
996
1062
|
|
|
1063
|
+
|
|
1064
|
+
/* ---------------- SEC-03 读侧可见性:被白名单拒绝的 repoUrl ≠ 「真的没配」 ---------------- */
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* 审计 SEC-01/02/03:已落盘的 repoUrl 过不了白名单就会被 readFullSyncConfig 丢掉。
|
|
1068
|
+
* 丢掉本身是对的(绝不把可疑地址交给凭据注入与 git 子进程),但自动同步若只说
|
|
1069
|
+
* 'unconfigured',用户看到的是「你没配」,实际是「你配过、被拒了」—— 永远查不出原因。
|
|
1070
|
+
* 所以未配置分支必须带上成因字段(配置跳过 + 历史各一份),且真没配时不产生幻影成因。
|
|
1071
|
+
*/
|
|
1072
|
+
|
|
1073
|
+
test('SEC-03:已存 repoUrl 被白名单拒绝 → skipped(unconfigured) 但带 configIssue(结果 + 历史)', async () => {
|
|
1074
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-config-issue-'));
|
|
1075
|
+
try {
|
|
1076
|
+
await fs.writeFile(
|
|
1077
|
+
path.join(dir, SYNC_CONFIG_FILE),
|
|
1078
|
+
JSON.stringify({ schemaVersion: 2, transport: 'git', git: { repoUrl: 'https://192.168.1.10/owner/repo.git' } }),
|
|
1079
|
+
'utf8',
|
|
1080
|
+
);
|
|
1081
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
1082
|
+
const entries: AutosyncHistoryEntry[] = [];
|
|
1083
|
+
// 故意不注入 readSyncConfigFn:走缺省 readSyncConfigFor(syncDir, channel, issues),钉住真实接线
|
|
1084
|
+
const scheduler = new AutoSyncScheduler({
|
|
1085
|
+
syncDir: dir,
|
|
1086
|
+
host: { log: nullLogger() },
|
|
1087
|
+
makeSyncEngine: () => { throw new Error('被拒地址绝不构造同步引擎'); },
|
|
1088
|
+
msg: (k: string) => k,
|
|
1089
|
+
runs: new RunRegistry(),
|
|
1090
|
+
now: () => new Date(1_000_000_000_000),
|
|
1091
|
+
readConfig: async () => cfg,
|
|
1092
|
+
writeConfig: async () => {},
|
|
1093
|
+
readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: entries, updatedAt: '' }),
|
|
1094
|
+
appendHistoryFn: async (e) => { entries.push(e); },
|
|
1095
|
+
});
|
|
1096
|
+
const result = await scheduler.runOnce('git');
|
|
1097
|
+
assert.equal(result.status, 'skipped');
|
|
1098
|
+
assert.equal(result.skipReason, 'unconfigured', '机读 token 保持稳定(客户端本地化不破)');
|
|
1099
|
+
assert.ok(
|
|
1100
|
+
(result.configIssue ?? '').includes('192.168.1.10'),
|
|
1101
|
+
'结果必须说明「地址被拒」,实际: ' + JSON.stringify(result),
|
|
1102
|
+
);
|
|
1103
|
+
assert.equal(entries.length, 1, '仍然写一条跳过历史');
|
|
1104
|
+
const entry = entries[0] as AutosyncHistoryEntry & { configIssue?: string };
|
|
1105
|
+
assert.equal(entry.skipReason, 'unconfigured');
|
|
1106
|
+
assert.ok(
|
|
1107
|
+
(entry.configIssue ?? '').includes('192.168.1.10'),
|
|
1108
|
+
'历史同一字段(界面按字段有无渲染「被拒」而不是「未配置」): ' + JSON.stringify(entry),
|
|
1109
|
+
);
|
|
1110
|
+
} finally {
|
|
1111
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test('SEC-03:真的没配 → 不带 configIssue(不产生幻影告警)', async () => {
|
|
1116
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
1117
|
+
const { scheduler, getEntries } = makeScheduler({ cfg, engine: {}, history: [], syncCfg: null });
|
|
1118
|
+
const result = await scheduler.runOnce('git');
|
|
1119
|
+
assert.equal(result.skipReason, 'unconfigured');
|
|
1120
|
+
assert.equal(result.configIssue, undefined, '空配置不是「被拒」,不许造一条假成因');
|
|
1121
|
+
const entry = getEntries()[0] as AutosyncHistoryEntry & { configIssue?: string } | undefined;
|
|
1122
|
+
assert.equal(entry?.configIssue, undefined);
|
|
1123
|
+
});
|