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
|
@@ -3,19 +3,27 @@
|
|
|
3
3
|
* 白名单收集(存在才收、白名单外不收、不递归)、Create/Skip/Conflict 分析、
|
|
4
4
|
* applyItem 写回 $DSH_HOME/dsh-config-manager/<rel>、默认包含 + portable 语义、
|
|
5
5
|
* createAdapters 的 selfDir 挂载行为(缺省挂载 / '' 不挂)。
|
|
6
|
+
*
|
|
7
|
+
* SEC-01(P0):sync/sync-config.json 与 sync/sync-autosync.json 都含「同步目标地址」,
|
|
8
|
+
* 允许导入写盘 = 备份包可以改本机同步目标,下一次同步把 token 送进包控制的主机。
|
|
9
|
+
* 因此两者退出裸写白名单:既不导出,也不允许导入写盘(包内出现 → 报告级 warning,绝不静默)。
|
|
6
10
|
*/
|
|
7
11
|
import test from 'node:test';
|
|
8
12
|
import assert from 'node:assert/strict';
|
|
9
13
|
import { createAdapters } from './index.ts';
|
|
10
|
-
import { SelfAdapter, SELF_CONFIG_FILES } from './self.ts';
|
|
14
|
+
import { SelfAdapter, SELF_CONFIG_FILES, SELF_IMPORT_BLOCKED_RELS, SELF_IMPORT_BLOCKED_CODE } from './self.ts';
|
|
11
15
|
import { hashSection } from '../sync/sync-state.ts';
|
|
16
|
+
import { sha256Hex } from '../utils/hashing.ts';
|
|
12
17
|
import { makeContext, makeImportContext } from './test-helpers.ts';
|
|
13
18
|
|
|
14
19
|
test('self: 白名单收集(存在才收,子目录路径保留,白名单外不收集)', async () => {
|
|
15
20
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
16
|
-
await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git","git":{"repoUrl":"https://x"},"webdav":{}}', 'utf8'));
|
|
17
21
|
await src.fs.writeFile('dsh-config-manager/sync/sync-selection.json', Buffer.from('{"schemaVersion":1,"mode":"default"}', 'utf8'));
|
|
18
22
|
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"webdav"}', 'utf8'));
|
|
23
|
+
await src.fs.writeFile('dsh-config-manager/market/market-config.json', Buffer.from('{"schemaVersion":1,"sources":[]}', 'utf8'));
|
|
24
|
+
// SEC-01:含同步目标地址的两个文件不再进载荷(否则备份包可借它改本机同步目标)
|
|
25
|
+
await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git","git":{"repoUrl":"https://x"}}', 'utf8'));
|
|
26
|
+
await src.fs.writeFile('dsh-config-manager/sync/sync-autosync.json', Buffer.from('{"schemaVersion":2}', 'utf8'));
|
|
19
27
|
// 白名单外:历史/缓存/快照/临时产物不得收集
|
|
20
28
|
await src.fs.writeFile('dsh-config-manager/sync/sync-history.json', Buffer.from('{"schemaVersion":1}', 'utf8'));
|
|
21
29
|
await src.fs.writeFile('dsh-config-manager/market/cache/index.json', Buffer.from('{}', 'utf8'));
|
|
@@ -26,14 +34,16 @@ test('self: 白名单收集(存在才收,子目录路径保留,白名单
|
|
|
26
34
|
const out = await adapter.export(src, { includeSecrets: false });
|
|
27
35
|
const rels = out.data.files.map((f) => f.relativePath).sort();
|
|
28
36
|
assert.deepEqual(rels, [
|
|
29
|
-
'
|
|
37
|
+
'market/market-config.json',
|
|
30
38
|
'sync/sync-selection.json',
|
|
31
39
|
'sync/ui-prefs.json',
|
|
32
40
|
]);
|
|
33
41
|
assert.equal(out.counts.files, 3);
|
|
34
42
|
assert.equal(out.warnings.length, 0, '存在文件时不告警');
|
|
35
|
-
//
|
|
36
|
-
assert.ok(SELF_CONFIG_FILES.includes('sync/sync-
|
|
43
|
+
// 白名单常量:SEC-01 移出两个同步目标文件,其余照旧
|
|
44
|
+
assert.ok(!SELF_CONFIG_FILES.includes('sync/sync-config.json'), 'SEC-01:sync-config.json 不得在裸写白名单里');
|
|
45
|
+
assert.ok(!SELF_CONFIG_FILES.includes('sync/sync-autosync.json'), 'SEC-01:sync-autosync.json 不得在裸写白名单里');
|
|
46
|
+
assert.ok(SELF_CONFIG_FILES.includes('sync/backup-schedule.json'), '调度配置(无同步目标地址)仍在白名单');
|
|
37
47
|
assert.ok(SELF_CONFIG_FILES.includes('market/market-config.json'));
|
|
38
48
|
});
|
|
39
49
|
|
|
@@ -53,7 +63,7 @@ test('self: 全部缺失 → 空分区 + dirEmpty 警告', async () => {
|
|
|
53
63
|
|
|
54
64
|
test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>;幂等 Skip;不同 Conflict)', async () => {
|
|
55
65
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
56
|
-
await src.fs.writeFile('dsh-config-manager/sync/
|
|
66
|
+
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1}', 'utf8'));
|
|
57
67
|
const adapter = new SelfAdapter();
|
|
58
68
|
const out = await adapter.export(src, { includeSecrets: false });
|
|
59
69
|
|
|
@@ -65,9 +75,9 @@ test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>
|
|
|
65
75
|
const r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
|
|
66
76
|
assert.equal(r.ok, true);
|
|
67
77
|
assert.equal(
|
|
68
|
-
Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/
|
|
69
|
-
'{"
|
|
70
|
-
'导入写回 $DSH_HOME/dsh-config-manager
|
|
78
|
+
Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/ui-prefs.json')).toString(),
|
|
79
|
+
'{"schemaVersion":1}',
|
|
80
|
+
'导入写回 $DSH_HOME/dsh-config-manager/<rel>(基准目录 + 相对路径)',
|
|
71
81
|
);
|
|
72
82
|
|
|
73
83
|
// 幂等:一致 → Skip
|
|
@@ -75,11 +85,52 @@ test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>
|
|
|
75
85
|
assert.equal(items[0]?.kind, 'Skip');
|
|
76
86
|
|
|
77
87
|
// 内容不同 → Conflict
|
|
78
|
-
await dst.fs.writeFile('dsh-config-manager/sync/
|
|
88
|
+
await dst.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"s3"}', 'utf8'));
|
|
79
89
|
items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
80
90
|
assert.equal(items[0]?.kind, 'Conflict');
|
|
81
91
|
});
|
|
82
92
|
|
|
93
|
+
/* ---------------- SEC-01:导入包不得写同步目标文件 ---------------- */
|
|
94
|
+
|
|
95
|
+
test('SEC-01:包内含 sync-config.json / sync-autosync.json → 报告级 warning + 绝不写盘', async () => {
|
|
96
|
+
const adapter = new SelfAdapter();
|
|
97
|
+
const evil = '{"schemaVersion":3,"transport":"git","git":{"repoUrl":"https://attacker.example/collect.git"}}';
|
|
98
|
+
const section = {
|
|
99
|
+
version: 1 as const,
|
|
100
|
+
files: SELF_IMPORT_BLOCKED_RELS.map((relativePath) => ({
|
|
101
|
+
relativePath,
|
|
102
|
+
data: Buffer.from(evil, 'utf8'),
|
|
103
|
+
contentHash: sha256Hex(Buffer.from(evil, 'utf8')),
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
const dst = makeContext('linux', '/home/bob');
|
|
107
|
+
const localConfig = '{"schemaVersion":3,"transport":"git","git":{"repoUrl":"https://github.com/u/r.git"},"webdav":{}}';
|
|
108
|
+
await dst.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from(localConfig, 'utf8'));
|
|
109
|
+
const ctx = makeImportContext(dst, new Map([['self', section]]));
|
|
110
|
+
|
|
111
|
+
const items = await adapter.analyzeImport(section, ctx);
|
|
112
|
+
assert.equal(items.length, SELF_IMPORT_BLOCKED_RELS.length);
|
|
113
|
+
for (const item of items) {
|
|
114
|
+
assert.equal(item.kind, 'Warning', '不写盘项必须是 Warning(引擎跳过:不进快照范围、不调 applyItem)');
|
|
115
|
+
assert.equal(item.severity, 'warning');
|
|
116
|
+
assert.ok((item.description ?? '').length > 0, '必须带可展示文案,不得静默');
|
|
117
|
+
assert.ok(String(item.detail ?? '').startsWith(SELF_IMPORT_BLOCKED_CODE), '必须带稳定机器码: ' + String(item.detail));
|
|
118
|
+
assert.ok(String(item.detail ?? '').includes(item.id.slice('self:'.length)), '机器码里带上具体 rel');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 纵深防御:即使绕过引擎直接 apply,也一字节都不写
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
const r = await adapter.applyItem(item, makeImportContext(dst, new Map([['self', section]])));
|
|
124
|
+
assert.equal(r.ok, false, '被挡下的项不得报告成功写入');
|
|
125
|
+
}
|
|
126
|
+
assert.equal(
|
|
127
|
+
Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/sync-config.json')).toString(),
|
|
128
|
+
localConfig,
|
|
129
|
+
'本机既有的同步目标不得被包内地址覆盖',
|
|
130
|
+
);
|
|
131
|
+
assert.equal(await dst.fs.exists('dsh-config-manager/sync/sync-autosync.json'), false, '不得凭空创建同步调度配置');
|
|
132
|
+
});
|
|
133
|
+
|
|
83
134
|
test('createAdapters: selfDir 缺省挂载 dsh-config-manager;空串不挂载', () => {
|
|
84
135
|
const withSelf = createAdapters({ selfDir: undefined });
|
|
85
136
|
assert.ok(withSelf.some((a) => a.id === 'self'), '缺省挂载 self adapter');
|
|
@@ -97,10 +148,10 @@ test('createAdapters: selfDir 缺省挂载 dsh-config-manager;空串不挂载'
|
|
|
97
148
|
|
|
98
149
|
test('self: 预览只列白名单 —— 快照/同步工作副本等内部数据不得进入选择器(与导出逐项相等)', async () => {
|
|
99
150
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
100
|
-
const syncConfig = Buffer.from('{"transport":"git"}', 'utf8');
|
|
101
151
|
const uiPrefs = Buffer.from('{"schemaVersion":1}', 'utf8');
|
|
102
|
-
|
|
152
|
+
const marketConfig = Buffer.from('{"schemaVersion":1,"sources":[]}', 'utf8');
|
|
103
153
|
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', uiPrefs);
|
|
154
|
+
await src.fs.writeFile('dsh-config-manager/market/market-config.json', marketConfig);
|
|
104
155
|
// 插件自身的数据:此前基类的目录递归把它们全当成该分区的「可勾选单元」
|
|
105
156
|
await src.fs.writeFile('dsh-config-manager/snapshots/eec/snapshot.json', Buffer.from('x'.repeat(4096), 'utf8'));
|
|
106
157
|
await src.fs.writeFile('dsh-config-manager/config-snapshots/2026/config-snapshot.json', Buffer.from('x'.repeat(4096), 'utf8'));
|
|
@@ -115,10 +166,10 @@ test('self: 预览只列白名单 —— 快照/同步工作副本等内部数
|
|
|
115
166
|
|
|
116
167
|
assert.deepEqual(
|
|
117
168
|
previewed.items.map((u) => u.id),
|
|
118
|
-
['self:sync/
|
|
169
|
+
['self:sync/ui-prefs.json', 'self:market/market-config.json'],
|
|
119
170
|
'预览只列白名单里的配置(回归:只覆写 export() 时会列出上述全部 8 个文件)',
|
|
120
171
|
);
|
|
121
|
-
assert.equal(previewed.sizeBytes,
|
|
172
|
+
assert.equal(previewed.sizeBytes, uiPrefs.byteLength + marketConfig.byteLength, '体积只算白名单文件');
|
|
122
173
|
assert.deepEqual(previewed.items, adapter.listUnits(exported), '预览的单元清单必须与导出逐项相同');
|
|
123
174
|
assert.deepEqual(previewed.section.counts, exported.counts);
|
|
124
175
|
assert.deepEqual(previewed.section.warnings, exported.warnings);
|
|
@@ -131,8 +182,8 @@ test('self: 预览只列白名单 —— 快照/同步工作副本等内部数
|
|
|
131
182
|
|
|
132
183
|
test('self: 磁盘体检新统计的目录(rollback-points / 遗留 checkpoints)不进本分区载荷', async () => {
|
|
133
184
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
134
|
-
const
|
|
135
|
-
await src.fs.writeFile('dsh-config-manager/sync/
|
|
185
|
+
const uiPrefs = Buffer.from('{"schemaVersion":1}', 'utf8');
|
|
186
|
+
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', uiPrefs);
|
|
136
187
|
// 磁盘体检会统计这两块占用(回滚点库 + 旧检查点库)——但它们是**数据**,不是 self 分区的配置
|
|
137
188
|
await src.fs.writeFile('dsh-config-manager/rollback-points/index.json', Buffer.from('x'.repeat(4096), 'utf8'));
|
|
138
189
|
await src.fs.writeFile('dsh-config-manager/rollback-points/objects/rb-1/session.v4.jsonl.zstd', Buffer.from('x'.repeat(4096), 'utf8'));
|
|
@@ -144,66 +195,59 @@ test('self: 磁盘体检新统计的目录(rollback-points / 遗留 checkpoint
|
|
|
144
195
|
const previewed = await adapter.preview(src, { includeSecrets: false });
|
|
145
196
|
const rels = exported.data.files.map((f) => f.relativePath);
|
|
146
197
|
|
|
147
|
-
assert.deepEqual(rels, ['sync/
|
|
198
|
+
assert.deepEqual(rels, ['sync/ui-prefs.json'], '白名单外一律不收(新增统计目录不得混进载荷)');
|
|
148
199
|
assert.equal(new Set(rels).size, rels.length, '不得出现重复条目');
|
|
149
200
|
assert.equal(exported.counts.files, 1);
|
|
150
|
-
assert.deepEqual(
|
|
151
|
-
|
|
152
|
-
['self:sync/sync-config.json'],
|
|
153
|
-
'预览与导出同口径(只列白名单)',
|
|
154
|
-
);
|
|
155
|
-
assert.equal(previewed.sizeBytes, syncConfig.byteLength, '体积只算白名单文件');
|
|
201
|
+
assert.deepEqual(previewed.items.map((u) => u.id), ['self:sync/ui-prefs.json'], '预览与导出同口径(只列白名单)');
|
|
202
|
+
assert.equal(previewed.sizeBytes, uiPrefs.byteLength, '体积只算白名单文件');
|
|
156
203
|
});
|
|
157
204
|
|
|
158
205
|
test('self: 宿主未实现 statSize(旧版门面)时,白名单判定仍只收配置、不误列内部数据', async () => {
|
|
159
206
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
160
|
-
await src.fs.writeFile('dsh-config-manager/sync/
|
|
207
|
+
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{}', 'utf8'));
|
|
161
208
|
await src.fs.writeFile('dsh-config-manager/snapshots/x/snapshot.json', Buffer.from('{}', 'utf8'));
|
|
162
209
|
Object.defineProperty(src.fs, 'statSize', { value: undefined, configurable: true });
|
|
163
210
|
|
|
164
211
|
const adapter = new SelfAdapter();
|
|
165
212
|
const previewed = await adapter.preview(src, { includeSecrets: false });
|
|
166
|
-
assert.deepEqual(previewed.items.map((u) => u.id), ['self:sync/
|
|
213
|
+
assert.deepEqual(previewed.items.map((u) => u.id), ['self:sync/ui-prefs.json']);
|
|
167
214
|
assert.equal(previewed.sizeBytes, 2, '退回 readFile 时体积 = 文件字节数');
|
|
168
215
|
});
|
|
169
216
|
|
|
170
217
|
/* ---------------- issue #73:调度配置不得因运行态漂移而每轮重现 ---------------- */
|
|
171
218
|
|
|
172
|
-
const AUTOSYNC_REL = 'dsh-config-manager/sync/sync-autosync.json';
|
|
173
219
|
const BACKUP_SCHEDULE_REL = 'dsh-config-manager/sync/backup-schedule.json';
|
|
174
220
|
|
|
221
|
+
/** backup-schedule.json 的配置本体(无运行态) */
|
|
222
|
+
function backupScheduleBody(overrides: Record<string, unknown> = {}, runState: Record<string, unknown> = {}): Buffer {
|
|
223
|
+
return Buffer.from(JSON.stringify({
|
|
224
|
+
schemaVersion: 1, enabled: true, interval: '24h', startupMinIntervalMs: 3600000,
|
|
225
|
+
consecutiveFailures: 0, ...overrides, ...runState,
|
|
226
|
+
}, null, 2), 'utf8');
|
|
227
|
+
}
|
|
228
|
+
|
|
175
229
|
test('self: 调度配置仅运行态不同 → Skip(键序不同亦然);配置本体不同 → Conflict', async () => {
|
|
176
230
|
const adapter = new SelfAdapter();
|
|
177
|
-
const remote = JSON.stringify({
|
|
178
|
-
schemaVersion: 2,
|
|
179
|
-
channels: {
|
|
180
|
-
git: { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success' },
|
|
181
|
-
webdav: { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 },
|
|
182
|
-
},
|
|
183
|
-
}, null, 2);
|
|
184
231
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
185
|
-
await src.fs.writeFile(
|
|
232
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
233
|
+
lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success',
|
|
234
|
+
}));
|
|
186
235
|
const out = await adapter.export(src, { includeSecrets: false });
|
|
187
236
|
const sections = new Map([['self', out.data]]);
|
|
188
237
|
|
|
189
238
|
// 本机:配置本体一致,但运行态已被本机调度器改写(每轮同步收尾都会发生),且键序不同
|
|
190
239
|
const dst = makeContext('linux', '/home/bob');
|
|
191
|
-
await dst.fs.writeFile(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
git: { lastRunAt: '2026-10-06T09:00:00.000Z', lastRunStatus: 'failed', lastRunMessage: '网络不可达', lastRunHistoryId: 'h-1', consecutiveFailures: 3, startupMinIntervalMs: 300000, interval: '30m', enabled: true },
|
|
196
|
-
},
|
|
240
|
+
await dst.fs.writeFile(BACKUP_SCHEDULE_REL, Buffer.from(JSON.stringify({
|
|
241
|
+
lastRunStatus: 'failed', lastRunMessage: '网络不可达', lastRunHistoryId: 'h-1', consecutiveFailures: 3,
|
|
242
|
+
startupMinIntervalMs: 3600000, interval: '24h', enabled: true, schemaVersion: 1,
|
|
243
|
+
lastRunAt: '2026-10-06T09:00:00.000Z',
|
|
197
244
|
}, null, 2), 'utf8'));
|
|
198
245
|
let items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
199
246
|
assert.equal(items.length, 1);
|
|
200
247
|
assert.equal(items[0]?.kind, 'Skip', '仅运行态字段不同(且键序不同)→ Skip,否则每轮同步都重现同一条 Conflict');
|
|
201
248
|
|
|
202
249
|
// 配置本体(enabled)不同 → 仍是 Conflict(剔除运行态不得把配置差异一起吞掉)
|
|
203
|
-
await dst.fs.writeFile(
|
|
204
|
-
schemaVersion: 2,
|
|
205
|
-
channels: { git: { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 } },
|
|
206
|
-
}, null, 2), 'utf8'));
|
|
250
|
+
await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({ enabled: false }));
|
|
207
251
|
items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
208
252
|
assert.equal(items[0]?.kind, 'Conflict');
|
|
209
253
|
});
|
|
@@ -211,18 +255,16 @@ test('self: 调度配置仅运行态不同 → Skip(键序不同亦然);
|
|
|
211
255
|
test('self: backup-schedule.json 同口径 + 坏 JSON 回落整文件哈希', async () => {
|
|
212
256
|
const adapter = new SelfAdapter();
|
|
213
257
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
214
|
-
await src.fs.writeFile(BACKUP_SCHEDULE_REL,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}, null, 2), 'utf8'));
|
|
258
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
259
|
+
lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success',
|
|
260
|
+
}));
|
|
218
261
|
const out = await adapter.export(src, { includeSecrets: false });
|
|
219
262
|
const sections = new Map([['self', out.data]]);
|
|
220
263
|
const dst = makeContext('linux', '/home/bob');
|
|
221
264
|
|
|
222
|
-
await dst.fs.writeFile(BACKUP_SCHEDULE_REL,
|
|
223
|
-
schemaVersion: 1, enabled: true, interval: '24h', startupMinIntervalMs: 3600000,
|
|
265
|
+
await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
224
266
|
consecutiveFailures: 2, lastRunAt: '2026-10-06T09:00:00.000Z', lastRunStatus: 'failed',
|
|
225
|
-
}
|
|
267
|
+
}));
|
|
226
268
|
let items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
227
269
|
assert.equal(items[0]?.kind, 'Skip');
|
|
228
270
|
|
|
@@ -235,39 +277,38 @@ test('self: backup-schedule.json 同口径 + 坏 JSON 回落整文件哈希', as
|
|
|
235
277
|
test('self: 其余白名单文件的比对口径不外溢(仍是整文件哈希)', async () => {
|
|
236
278
|
const adapter = new SelfAdapter();
|
|
237
279
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
238
|
-
await src.fs.writeFile('dsh-config-manager/sync/
|
|
280
|
+
await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"webdav"}', 'utf8'));
|
|
239
281
|
const out = await adapter.export(src, { includeSecrets: false });
|
|
240
282
|
const sections = new Map([['self', out.data]]);
|
|
241
283
|
const dst = makeContext('linux', '/home/bob');
|
|
242
|
-
await dst.fs.writeFile('dsh-config-manager/sync/
|
|
284
|
+
await dst.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{ "lastSyncChannel": "webdav", "schemaVersion": 1 }', 'utf8'));
|
|
243
285
|
const items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
|
|
244
|
-
assert.equal(items[0]?.kind, 'Conflict', '键序不同但语义相同 ——
|
|
286
|
+
assert.equal(items[0]?.kind, 'Conflict', '键序不同但语义相同 —— 非调度配置仍按字节判');
|
|
245
287
|
});
|
|
246
288
|
|
|
247
289
|
/**
|
|
248
290
|
* issue #127 回归:运行态漂移不得改变导出的分区 hash。
|
|
249
291
|
*
|
|
250
|
-
*
|
|
292
|
+
* 现场:调度器每轮收尾改写调度配置的运行态字段 → self 分区 hashSection 每轮都变
|
|
251
293
|
* → SyncEngine.hasLocalChanges() 永远为真 → 自动同步的每次远端轮询(缺省 60s)都真推一个
|
|
252
294
|
* 只差 lastRunAt 的新快照(真机 2026-10-25 一天 41 次 push,远端累积 325 个垃圾快照)。
|
|
295
|
+
*
|
|
296
|
+
* 载体说明:SEC-01 之后 sync-autosync.json 不再进载荷,同一套运行态剔除语义由仍在白名单的
|
|
297
|
+
* backup-schedule.json 承载(两个调度配置共用同一份剔除表,见 self.ts 的 VOLATILE_RUN_STATE_KEY_LIST)。
|
|
253
298
|
*/
|
|
254
299
|
test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判「本地有改动」)', async () => {
|
|
255
|
-
const rel = 'dsh-config-manager/sync/sync-autosync.json';
|
|
256
300
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
257
301
|
const adapter = new SelfAdapter();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
webdav: { enabled: false },
|
|
263
|
-
},
|
|
264
|
-
}, null, 2);
|
|
265
|
-
|
|
266
|
-
await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-25T12:00:00.000Z', lastRunStatus: 'success', lastRunHistoryId: 'h-1', consecutiveFailures: 0 }, 'https://x/repo.git'), 'utf8'));
|
|
302
|
+
|
|
303
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
304
|
+
lastRunAt: '2026-10-25T12:00:00.000Z', lastRunStatus: 'success', lastRunHistoryId: 'h-1', consecutiveFailures: 0,
|
|
305
|
+
}));
|
|
267
306
|
const first = await adapter.export(src, { includeSecrets: false });
|
|
268
307
|
|
|
269
308
|
// 模拟一次同步收尾:只推进运行态(配置本体不动)
|
|
270
|
-
await src.fs.writeFile(
|
|
309
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
310
|
+
lastRunAt: '2026-10-25T12:01:00.000Z', lastRunStatus: 'failed', lastRunMessage: 'boom', lastRunHistoryId: 'h-2', consecutiveFailures: 1,
|
|
311
|
+
}));
|
|
271
312
|
const second = await adapter.export(src, { includeSecrets: false });
|
|
272
313
|
|
|
273
314
|
assert.equal(
|
|
@@ -275,13 +316,13 @@ test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判
|
|
|
275
316
|
hashSection(first.data),
|
|
276
317
|
'仅运行态不同 → 分区 hash 必须不变(否则每轮远端轮询都真推一次)',
|
|
277
318
|
);
|
|
278
|
-
const payload = Buffer.from(second.data.files.find((f) => f.relativePath === 'sync/
|
|
319
|
+
const payload = Buffer.from(second.data.files.find((f) => f.relativePath === 'sync/backup-schedule.json')!.data).toString('utf8');
|
|
279
320
|
assert.ok(!payload.includes('lastRunAt'), '导出载荷不含运行态:远端不该接收本机执行记录');
|
|
280
321
|
assert.ok(!payload.includes('consecutiveFailures'));
|
|
281
|
-
assert.ok(payload.includes('"
|
|
322
|
+
assert.ok(payload.includes('"interval":"24h"'), '配置本体仍在载荷里');
|
|
282
323
|
|
|
283
324
|
// 配置本体变化必须被识别(不能把运行态过滤做成「什么都判不变」)
|
|
284
|
-
await src.fs.writeFile(
|
|
325
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({ interval: '12h' }, { lastRunAt: '2026-10-25T12:02:00.000Z' }));
|
|
285
326
|
const third = await adapter.export(src, { includeSecrets: false });
|
|
286
327
|
assert.notEqual(hashSection(third.data), hashSection(second.data), '真实配置变化必须让分区 hash 变化');
|
|
287
328
|
});
|
|
@@ -296,46 +337,46 @@ test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判
|
|
|
296
337
|
* → hasLocalChanges() 恒真 → 每轮都真推一个新快照。
|
|
297
338
|
*/
|
|
298
339
|
test('self: 跳过码翻转不改变分区 hash(issue #119:自动同步不再每轮真推快照)', async () => {
|
|
299
|
-
const rel = 'dsh-config-manager/sync/sync-autosync.json';
|
|
300
340
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
301
341
|
const adapter = new SelfAdapter();
|
|
302
|
-
const config = (runState: Record<string, unknown>, repoUrl = 'https://x/repo.git') => JSON.stringify({
|
|
303
|
-
schemaVersion: 2,
|
|
304
|
-
channels: {
|
|
305
|
-
git: { enabled: true, intervalMinutes: 30, realtime: true, remoteCheckMs: 60000, repoUrl, branch: 'main', ...runState },
|
|
306
|
-
webdav: { enabled: false },
|
|
307
|
-
},
|
|
308
|
-
}, null, 2);
|
|
309
342
|
|
|
310
343
|
// 轮次 A:跳过(无本地改动)
|
|
311
|
-
await src.fs.writeFile(
|
|
344
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
345
|
+
lastRunAt: '2026-10-29T09:00:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate', consecutiveFailures: 0,
|
|
346
|
+
}));
|
|
312
347
|
const skipRound = await adapter.export(src, { includeSecrets: false });
|
|
313
348
|
|
|
314
349
|
// 轮次 B:真推一次 —— 跳过码被清空,其余与 A 相同
|
|
315
|
-
await src.fs.writeFile(
|
|
350
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
351
|
+
lastRunAt: '2026-10-29T09:01:00.000Z', lastRunStatus: 'success', consecutiveFailures: 0,
|
|
352
|
+
}));
|
|
316
353
|
const pushRound = await adapter.export(src, { includeSecrets: false });
|
|
317
354
|
|
|
318
355
|
// 轮次 C:跳过(冲突,带待确认分区数)
|
|
319
|
-
await src.fs.writeFile(
|
|
356
|
+
await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
357
|
+
lastRunAt: '2026-10-29T09:02:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'conflict', lastRunSkipCount: 3, consecutiveFailures: 0,
|
|
358
|
+
}));
|
|
320
359
|
const conflictRound = await adapter.export(src, { includeSecrets: false });
|
|
321
360
|
|
|
322
361
|
const base = hashSection(skipRound.data);
|
|
323
362
|
assert.equal(hashSection(pushRound.data), base, '跳过码被清空不得改变分区 hash(否则每轮都判「本地有改动」)');
|
|
324
363
|
assert.equal(hashSection(conflictRound.data), base, '跳过码换值 / 带分区数不得改变分区 hash');
|
|
325
364
|
|
|
326
|
-
const payload = Buffer.from(pushRound.data.files.find((f) => f.relativePath === 'sync/
|
|
365
|
+
const payload = Buffer.from(pushRound.data.files.find((f) => f.relativePath === 'sync/backup-schedule.json')!.data).toString('utf8');
|
|
327
366
|
assert.ok(!payload.includes('lastRunSkip'), '导出载荷不含跳过码:远端不该接收本机执行记录');
|
|
328
|
-
assert.ok(payload.includes('"
|
|
367
|
+
assert.ok(payload.includes('"interval":"24h"'), '配置本体仍在载荷里');
|
|
329
368
|
|
|
330
369
|
// 同一份远端载荷回灌本机(本机多出跳过码)→ Skip,不是 Conflict
|
|
331
370
|
const dst = makeContext('linux', '/home/bob');
|
|
332
|
-
await dst.fs.writeFile(
|
|
371
|
+
await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
|
|
372
|
+
lastRunAt: '2026-10-29T09:05:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate',
|
|
373
|
+
}));
|
|
333
374
|
const sections = new Map([['self', pushRound.data]]);
|
|
334
375
|
let items = await adapter.analyzeImport(pushRound.data, makeImportContext(dst, sections));
|
|
335
376
|
assert.equal(items[0]?.kind, 'Skip', '仅跳过码不同 → Skip(此前每轮判 Conflict 并重复推送)');
|
|
336
377
|
|
|
337
378
|
// 配置本体变化仍必须被识别(不能把剔除做成「什么都判不变」)
|
|
338
|
-
await dst.fs.writeFile(
|
|
379
|
+
await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({ interval: '6h' }, { lastRunSkipReason: 'upToDate' }));
|
|
339
380
|
items = await adapter.analyzeImport(pushRound.data, makeImportContext(dst, sections));
|
|
340
381
|
assert.equal(items[0]?.kind, 'Conflict', '真实配置变化必须仍判 Conflict');
|
|
341
|
-
});
|
|
382
|
+
});
|
package/src/adapters/self.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* self 分区 adapter(插件自身配置,设计「self 分区」):
|
|
3
3
|
* 数据源 = $DSH_HOME/dsh-config-manager/ 下的插件自身配置文件(白名单收集,非递归):
|
|
4
|
-
* - sync/sync-config.json 同步通道配置(git repoUrl / webdav url+username;凭据值走 credentials 槽位,不含 secret)
|
|
5
|
-
* - sync/sync-autosync.json 自动同步调度配置
|
|
6
4
|
* - sync/sync-selection.json 同步分区选择(默认/高级模式 + 勾选分区)
|
|
7
5
|
* - sync/ui-prefs.json 插件 UI 偏好(如上次选择的同步通道;从 localStorage 迁入)
|
|
6
|
+
* - sync/backup-schedule.json 备份调度配置
|
|
8
7
|
* - market/market-config.json 配置市场配置
|
|
8
|
+
* - exports/.backup-notes.json 导出产物备注清单
|
|
9
|
+
*
|
|
10
|
+
* **可信边界(SEC-01)**:`sync/sync-config.json` 与 `sync/sync-autosync.json` 是「同步目标」本身,
|
|
11
|
+
* 永不可由**包**决定 —— 一个把 repoUrl 指向攻击者仓库的包,导入完用户下一次同步就把家目录配置
|
|
12
|
+
* 推过去。它们不在白名单里(不导出),导入侧另由 `SELF_IMPORT_BLOCKED_RELS` 显式挡下并留 warning。
|
|
9
13
|
*
|
|
10
14
|
* 排除项:sync/sync-history.json(执行记录,属数据非配置)、market/cache/(缓存)、
|
|
11
15
|
* snapshots/(快照)、tmp/ exports/(临时/导出产物)、rollback-points/(回滚点库——磁盘体检
|
|
@@ -44,17 +48,22 @@
|
|
|
44
48
|
* 安全不变量:配置文件本身不含凭据值(同步凭据走 DSH credentials 槽位引用),
|
|
45
49
|
* 且文件类分区不进 SecretScanner(与 pluginFiles/skills 同语义)。
|
|
46
50
|
*/
|
|
47
|
-
import type { HostContext } from '../core/types.ts';
|
|
51
|
+
import type { ApplyResult, HostContext, ImportContext, PlanItem } from '../core/types.ts';
|
|
52
|
+
import type { FilesSection } from '../schema/types.ts';
|
|
48
53
|
import type { AutosyncConfig } from '../sync/autosync-config.ts';
|
|
49
54
|
import type { BackupScheduleConfig } from '../sync/backup-schedule-config.ts';
|
|
50
55
|
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
51
56
|
import { sectionMeta } from '../schema/section-registry.ts';
|
|
52
57
|
import { FileCollectionAdapter } from './file-collection.ts';
|
|
53
58
|
|
|
54
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* self 分区白名单文件(相对 baseDir,即 $DSH_HOME/dsh-config-manager/)。
|
|
61
|
+
*
|
|
62
|
+
* `sync/sync-config.json` 与 `sync/sync-autosync.json` **不在其中**(SEC-01):同步目标与自动同步开关
|
|
63
|
+
* 属于「目标机自己的决定」,跨机搬运等于让包决定把本机配置推到哪里。白名单只管导出侧;
|
|
64
|
+
* 导入侧的包内容不受白名单约束,所以另有 `SELF_IMPORT_BLOCKED_RELS` 显式挡下。
|
|
65
|
+
*/
|
|
55
66
|
export const SELF_CONFIG_FILES: readonly string[] = [
|
|
56
|
-
'sync/sync-config.json',
|
|
57
|
-
'sync/sync-autosync.json',
|
|
58
67
|
'sync/sync-selection.json',
|
|
59
68
|
'sync/ui-prefs.json',
|
|
60
69
|
'sync/backup-schedule.json',
|
|
@@ -64,6 +73,25 @@ export const SELF_CONFIG_FILES: readonly string[] = [
|
|
|
64
73
|
'exports/.backup-notes.json',
|
|
65
74
|
];
|
|
66
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 导入侧**一律不写盘**的 rel(SEC-01)。
|
|
78
|
+
*
|
|
79
|
+
* 与白名单的关系:白名单管「导出带什么」,本清单管「包里有也绝不落地」。两者故意不共用一份
|
|
80
|
+
* 数组 —— 白名单将来若因别的理由重新收进某个文件,也不该连带解除导入侧的保护。
|
|
81
|
+
*/
|
|
82
|
+
export const SELF_IMPORT_BLOCKED_RELS: readonly string[] = [
|
|
83
|
+
'sync/sync-config.json',
|
|
84
|
+
'sync/sync-autosync.json',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 拦截报告项里的**稳定机器码**前缀(`self-import-blocked:<rel>`)。
|
|
89
|
+
*
|
|
90
|
+
* 为什么要有码而不是只给文案:文案随语言/措辞变,调用方(报告、日志、将来的自动判定)需要
|
|
91
|
+
* 一个能逐字比对的判据。
|
|
92
|
+
*/
|
|
93
|
+
export const SELF_IMPORT_BLOCKED_CODE = "self-import-blocked";
|
|
94
|
+
|
|
67
95
|
/**
|
|
68
96
|
* 调度配置里「运行态字段」的**命名口径**:调度器每轮收尾都会改写的字段名形状
|
|
69
97
|
* (`lastRun*` 一族 + `consecutiveFailures`)。剔除表必须覆盖两个调度配置的并集,
|
|
@@ -223,4 +251,43 @@ export class SelfAdapter extends FileCollectionAdapter {
|
|
|
223
251
|
// 回落原引用(解析失败 / 非对象):走整文件哈希,且不额外重算包内哈希
|
|
224
252
|
return canonicalizeWithoutRunState(data) ?? data;
|
|
225
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 导入侧拦截(SEC-01):包里带 `sync/sync-config.json` / `sync/sync-autosync.json` → 改判 Warning。
|
|
257
|
+
*
|
|
258
|
+
* 为什么必须在**计划阶段**改判而不是只在 applyItem 里拒绝:这两份文件是「同步目标」本身,
|
|
259
|
+
* 一个把 repoUrl 指向攻击者仓库的包,导入完用户下一次同步就会把本机配置推过去 —— 而用户
|
|
260
|
+
* 从来没被问过「要不要换同步目标」。去掉 `target` 后引擎不会为它登记快照、也不会调 applyItem;
|
|
261
|
+
* 同时留下一项 report 级 warning(带稳定码 `self-import-blocked:<rel>`),让用户看得见
|
|
262
|
+
* 「包里有这个文件、我没写」,绝不静默。
|
|
263
|
+
*/
|
|
264
|
+
override async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
265
|
+
const items = await super.analyzeImport(data, ctx);
|
|
266
|
+
return items.map((item) => {
|
|
267
|
+
const rel = item.target?.ref;
|
|
268
|
+
if (rel === undefined || !SELF_IMPORT_BLOCKED_RELS.includes(rel)) return item;
|
|
269
|
+
const blocked: PlanItem = {
|
|
270
|
+
id: item.id,
|
|
271
|
+
kind: 'Warning',
|
|
272
|
+
adapter: this.id,
|
|
273
|
+
description: ctx.msg('adapter.selfImportBlocked', { path: rel }),
|
|
274
|
+
detail: `${SELF_IMPORT_BLOCKED_CODE}:${rel}`,
|
|
275
|
+
severity: 'warning',
|
|
276
|
+
};
|
|
277
|
+
if (item.unitId !== undefined) blocked.unitId = item.unitId;
|
|
278
|
+
return blocked;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 纵深防御(SEC-01):即使有人绕过引擎直接把被拦项喂进 applyItem,也一字节都不写。
|
|
284
|
+
* 报 warning=true(非致命、不触发整体回滚),与计划阶段的判定同源。
|
|
285
|
+
*/
|
|
286
|
+
override async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
|
|
287
|
+
const ref = item.target?.ref;
|
|
288
|
+
if (ref !== undefined && SELF_IMPORT_BLOCKED_RELS.includes(ref)) {
|
|
289
|
+
return { ok: false, message: ctx.msg('adapter.selfImportBlocked', { path: ref }), warning: true };
|
|
290
|
+
}
|
|
291
|
+
return super.applyItem(item, ctx);
|
|
292
|
+
}
|
|
226
293
|
}
|
|
@@ -649,9 +649,6 @@ const navLayoutResult = navWidths === null
|
|
|
649
649
|
<ImportWizardView
|
|
650
650
|
api={api}
|
|
651
651
|
t={t}
|
|
652
|
-
uiT={uiT}
|
|
653
|
-
// 向导 → 来源选择:同样是改 Task 入参(面板状态只有一个事实源)
|
|
654
|
-
onOpenForeignImport={() => { runStore.patch({ task: { ...activeTask, payload: { foreign: true } } }) }}
|
|
655
652
|
/>
|
|
656
653
|
))}
|
|
657
654
|
{activeTask.kind === 'market' && (
|
|
@@ -42,6 +42,7 @@ import PackageCheck from 'lucide-react/dist/esm/icons/package-check.mjs'
|
|
|
42
42
|
import Pencil from 'lucide-react/dist/esm/icons/pencil.mjs'
|
|
43
43
|
import RefreshCw from 'lucide-react/dist/esm/icons/refresh-cw.mjs'
|
|
44
44
|
import RotateCw from 'lucide-react/dist/esm/icons/rotate-cw.mjs'
|
|
45
|
+
import Plug from 'lucide-react/dist/esm/icons/plug.mjs'
|
|
45
46
|
import Search from 'lucide-react/dist/esm/icons/search.mjs'
|
|
46
47
|
import Trash2 from 'lucide-react/dist/esm/icons/trash-2.mjs'
|
|
47
48
|
import TriangleAlert from 'lucide-react/dist/esm/icons/triangle-alert.mjs'
|
|
@@ -63,6 +64,7 @@ const ICONS = {
|
|
|
63
64
|
clock: Clock,
|
|
64
65
|
history: History,
|
|
65
66
|
about: Info,
|
|
67
|
+
probe: Plug,
|
|
66
68
|
info: Info,
|
|
67
69
|
lock: Lock,
|
|
68
70
|
message: MessageSquare,
|
|
@@ -236,6 +238,7 @@ export const ActivityIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon n
|
|
|
236
238
|
export const ClockIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="clock" {...p} />
|
|
237
239
|
export const HistoryIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="history" {...p} />
|
|
238
240
|
export const AboutIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="about" {...p} />
|
|
241
|
+
export const ProbeIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="probe" {...p} />
|
|
239
242
|
export const MessageIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="message" {...p} />
|
|
240
243
|
export const PencilIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="pencil" {...p} />
|
|
241
244
|
export const CloseIcon = (p: Omit<IconProps, 'name'>): JSX.Element => <Icon name="close" {...p} />
|
|
@@ -48,8 +48,13 @@ export function SectionComposition({ sections, t, sectionLabel }: SectionComposi
|
|
|
48
48
|
<div className={css.sectionGrid}>
|
|
49
49
|
{sections.map((s) => {
|
|
50
50
|
const state = s.state ?? 'ready'
|
|
51
|
+
/* 真·空行降权(2026-11-01):条目与体积都为 0 的分区(如未启用的 Agent Presets)
|
|
52
|
+
与有内容的行同权重,会让「这一项什么都没有」读成和「941 条」一样重要。
|
|
53
|
+
app-screens §13:零值不给状态色,用灰 —— 有内容但 0 条目的行(如 Prompts 0 条 / 26 B)
|
|
54
|
+
保持正常色,因为它确实带着配置。 */
|
|
55
|
+
const isEmptyRow = state === 'ready' && s.count === 0 && s.sizeBytes === 0
|
|
51
56
|
return (
|
|
52
|
-
<div key={s.section} className={css.sectionRow}>
|
|
57
|
+
<div key={s.section} className={isEmptyRow ? `${css.sectionRow} ${css.sectionRowEmpty}` : css.sectionRow}>
|
|
53
58
|
<span className={css.sectionName}>{sectionLabel?.(s.section) ?? s.section}</span>
|
|
54
59
|
{state === 'ready'
|
|
55
60
|
? (
|
|
@@ -103,11 +103,11 @@ const MOVE_PINS: readonly MovePin[] = [
|
|
|
103
103
|
{ key: 'webdav.usernameHint', file: 'src/client/sync/ChannelConfigDialog.tsx', kind: '输入规则' },
|
|
104
104
|
{ key: 'config.saveHint', file: 'src/client/sync/ChannelConfigDialog.tsx', kind: '机制' },
|
|
105
105
|
{ key: 'mode.sessionsPickHint', file: 'src/client/sync/SyncSectionPickerDialog.tsx', kind: '机制' },
|
|
106
|
-
{ key: 'mode.pickerHint', file: 'src/client/sync/SyncSectionPickerDialog.tsx', kind: '机制' },
|
|
107
106
|
{ key: 'mode.sessionsLimitHint', file: 'src/client/sync/SyncSectionPickerDialog.tsx', kind: '输入规则' },
|
|
108
107
|
{ key: 'mode.sectionsHint', file: 'src/client/sync/SyncSectionPickerDialog.tsx', kind: '机制' },
|
|
109
108
|
{ key: 'mode.hint', file: 'src/client/sync/SyncPanel.tsx', kind: '机制' },
|
|
110
|
-
|
|
109
|
+
// 2026-11-01 用户要求:删掉两处「保存/即时生效」ⓘ(弹窗底部与同步页分区行)——
|
|
110
|
+
// 它们在解释用户无从选择的事,MOVE 台账随之退出 2 条。
|
|
111
111
|
{ key: 'channel.openHint', file: 'src/client/sync/SyncChannelEntryCard.tsx', kind: '机制' },
|
|
112
112
|
{ key: 'autosync.description', file: 'src/client/sync/AutosyncCard.tsx', kind: '机制' },
|
|
113
113
|
{ key: 'autosync.intervalHint', file: 'src/client/sync/AutosyncCard.tsx', kind: '机制' },
|
|
@@ -155,7 +155,6 @@ const MOVE_PINS: readonly MovePin[] = [
|
|
|
155
155
|
{ key: 'import.decryptArchive.hint', file: 'src/client/import/import-wizard-steps.tsx', kind: '机制' },
|
|
156
156
|
{ key: 'import.conflicts.hint', file: 'src/client/import/import-wizard-steps.tsx', kind: '机制' },
|
|
157
157
|
{ key: 'import.paths.hint', file: 'src/client/import/import-wizard-steps.tsx', kind: '机制' },
|
|
158
|
-
{ key: 'foreign.source.hint', file: 'src/client/import/import-wizard-steps.tsx', kind: '机制' },
|
|
159
158
|
{ key: 'import.secrets.hint', file: 'src/client/import/import-wizard-steps.tsx', kind: '机制' },
|
|
160
159
|
{ key: 'import.picker.hint', file: 'src/client/import/ImportWizardView.tsx', kind: '机制' },
|
|
161
160
|
{ key: 'foreign.source.hint', file: 'src/client/import/ForeignImportView.tsx', kind: '机制' },
|
|
@@ -385,14 +384,17 @@ function parseInfoHintEntry(rel: string): { zh: string | undefined; en: string |
|
|
|
385
384
|
return { zh: values[0], en: values[1], count: values.length };
|
|
386
385
|
}
|
|
387
386
|
|
|
388
|
-
test('t6-1 MOVE:
|
|
387
|
+
test('t6-1 MOVE:79 个说明键都已收进 ⓘ(逐字复用原键)且不再有可见说明行', () => {
|
|
389
388
|
const files = readClientSources();
|
|
390
389
|
// 扫描范围自检:客户端源码至少覆盖 t6 涉及的面板目录
|
|
391
390
|
const dirs = new Set(MOVE_PINS.map((p) => p.file.split('/').slice(0, 3).join('/')));
|
|
392
391
|
assert.ok(dirs.size >= 8, 'MOVE 清单必须覆盖全部面板目录(实际 ' + String(dirs.size) + ' 个)');
|
|
393
392
|
// 2026-10-27 文案审计:about.subtitle 是「页面目录式副标题」(插件信息、作者与反馈入口),
|
|
394
393
|
// 页面本身已由卡片表达,随页级标题一并删除 ⇒ MOVE 清单从 82 降到 81(不是删检查项)。
|
|
395
|
-
|
|
394
|
+
// 2026-11-01:删掉「改动即时生效并保存」「分区选择已保存到本机」两处 ⓘ(用户要求,
|
|
395
|
+
// 属解释「用户无从选择的事」的废话)⇒ 81 降到 79(不是删检查项)。
|
|
396
|
+
// 2026-11-01:导入选择页的「从其它 agent 导入」按钮与其 ⓘ 一并移除(首页已有直达入口)⇒ 79 降到 78。
|
|
397
|
+
assert.ok(MOVE_PINS.length === 78, 'MOVE 清单长度应为 78(实际 ' + String(MOVE_PINS.length) + ')');
|
|
396
398
|
assert.ok(
|
|
397
399
|
MOVE_PINS.filter((p) => p.kind === '输入规则').length >= 5,
|
|
398
400
|
'输入规则类 MOVE 至少 5 处(config.repoUrlHint / webdav.urlHint / mode.sessionsLimitHint / retention.keep*Hint / export.fileNameHint)',
|