dsh-config-manager 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1256 -1185
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +56 -2
- package/lib/sync/git/git-transport.js +344 -73
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +6 -1
- package/src/sync/git/git-transport.test.ts +351 -14
- package/src/sync/git/git-transport.ts +343 -79
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
- package/src/utils/git-fast-forward.ts +8 -3
package/lib/core/analyzer.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface AnalyzerOptions {
|
|
|
31
31
|
relativePath: string;
|
|
32
32
|
data: Uint8Array;
|
|
33
33
|
}[]) => SessionFormatProbeResult;
|
|
34
|
+
/** SEC-05:按分区豁免「表外条目阻断」(缺省空 = 严格);判据见 security/unverified-file-blocks.ts */
|
|
35
|
+
extraEntryExemptSections?: readonly SectionId[];
|
|
34
36
|
}
|
|
35
37
|
/** m1:每完成一个计划项的进度回调信息(Host 侧 run 状态更新用) */
|
|
36
38
|
export interface PlanItemProgress {
|
|
@@ -55,6 +57,7 @@ export declare class Analyzer {
|
|
|
55
57
|
private readonly parseZipFn;
|
|
56
58
|
private readonly msg;
|
|
57
59
|
private readonly sessionFormatProbe?;
|
|
60
|
+
private readonly extraEntryExemptSections;
|
|
58
61
|
/**
|
|
59
62
|
* 会话内 bundle 缓存(zipPath → { 身份, 解析结果 })。
|
|
60
63
|
*
|
package/lib/core/analyzer.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { sha256Hex } from '../utils/hashing.js';
|
|
13
|
-
import { parseJsonSafe } from '../utils/json.js';
|
|
14
13
|
import { SECTION_FILE_PREFIXES, SECTION_IDS, SECTION_JSON_PATHS, isFileSection, validateSectionData, } from '../schema/config.js';
|
|
15
|
-
import {
|
|
14
|
+
import { MANIFEST_FILE, parseManifest } from '../schema/manifest.js';
|
|
16
15
|
import { CURRENT_SCHEMA_VERSION, canImport, describeVersion, isSupported, isTooNew, needsMigration, } from '../schema/versions.js';
|
|
17
16
|
import { parseZip } from '../utils/zip.js';
|
|
18
17
|
import { loadTombstones } from '../schema/tombstones.js';
|
|
19
18
|
import { DEFAULT_SENSITIVE_RELS, restoreVaultFiles } from '../security/vault.js';
|
|
19
|
+
import { applyUnverifiedFileBlocks, scanArchiveIntegrity, unverifiedNoticeText } from '../security/unverified-file-blocks.js';
|
|
20
|
+
import { UNVERIFIED_FILE_ENTRY_CODE } from '../security/integrity.js';
|
|
20
21
|
import { createSnapshot, fsForSection, planItemWritesTarget, resolveFileTarget, resolveFileTargetRel } from './backup.js';
|
|
21
22
|
import { isCredentialConfigured } from './credential-status.js';
|
|
22
23
|
import { rollback } from './rollback.js';
|
|
@@ -127,6 +128,7 @@ export class Analyzer {
|
|
|
127
128
|
parseZipFn;
|
|
128
129
|
msg;
|
|
129
130
|
sessionFormatProbe;
|
|
131
|
+
extraEntryExemptSections;
|
|
130
132
|
/**
|
|
131
133
|
* 会话内 bundle 缓存(zipPath → { 身份, 解析结果 })。
|
|
132
134
|
*
|
|
@@ -149,6 +151,7 @@ export class Analyzer {
|
|
|
149
151
|
this.parseZipFn = opts.parseZipOverride ?? parseZip;
|
|
150
152
|
this.msg = opts.msg ?? msgOf(opts.ctx);
|
|
151
153
|
this.sessionFormatProbe = opts.sessionFormatProbe;
|
|
154
|
+
this.extraEntryExemptSections = opts.extraEntryExemptSections ?? [];
|
|
152
155
|
}
|
|
153
156
|
/* ---------------- 第 1-6 步:ZIP 读入 → 安全解析 → manifest → 完整性 → schema ---------------- */
|
|
154
157
|
/**
|
|
@@ -195,55 +198,13 @@ export class Analyzer {
|
|
|
195
198
|
catch (err) {
|
|
196
199
|
throw new Error(this.msg('import.manifestParseFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
197
200
|
}
|
|
198
|
-
// 4. 完整性(
|
|
201
|
+
// 4. 完整性(checksums.json 逐一 SHA-256)+ 4b 反向覆盖(表外条目):判据下沉在 security 侧。
|
|
199
202
|
const zipWarnings = [];
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const table = archive.has(CHECKSUMS_FILE)
|
|
206
|
-
? parseJsonSafe(archive.readEntryText(CHECKSUMS_FILE))
|
|
207
|
-
: null;
|
|
208
|
-
if (table === null || Object.keys(table).length === 0) {
|
|
209
|
-
zipWarnings.push(this.msg('import.checksumsMissing'));
|
|
210
|
-
}
|
|
211
|
-
if (table !== null && Object.keys(table).length > 0) {
|
|
212
|
-
const entries = new Map();
|
|
213
|
-
for (const name of archive.names()) {
|
|
214
|
-
if (name === MANIFEST_FILE || name === CHECKSUMS_FILE)
|
|
215
|
-
continue;
|
|
216
|
-
try {
|
|
217
|
-
// 异步解压:这是全仓**唯一**逐条解压全部条目的地方,`inflateRawSync` 会整段阻塞
|
|
218
|
-
// 事件循环(实测 29 MiB 归档 108 ms,大备份就是几秒的界面冻结)。判据与同步版逐字相同。
|
|
219
|
-
entries.set(name, await archive.readEntryAsync(name));
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
entries.delete(name); // 损坏条目在完整性阶段即失败
|
|
223
|
-
checksums.ok = false;
|
|
224
|
-
checksums.mismatches.push(name);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
const result = await verifyAgainstTable(entries, table);
|
|
228
|
-
checksums = result;
|
|
229
|
-
if (!checksums.ok) {
|
|
230
|
-
throw new Error(this.msg('import.integrityFailed', {
|
|
231
|
-
entries: [...checksums.mismatches, ...checksums.missing].map((m) => `"${m}"`).join(', '),
|
|
232
|
-
}));
|
|
233
|
-
}
|
|
234
|
-
// 4b. G-04:反向完整性——ZIP 内**不在**校验表里的条目(未登记 ⇒ 未被校验)。
|
|
235
|
-
// verifyAgainstTable 只遍历表里的键,所以 ZIP 里多出的条目此前既不校验也不告知;
|
|
236
|
-
// 这里把它变成显式 warning(不阻断:未知 ZIP 条目不参与校验是格式 v1 的既定语义)。
|
|
237
|
-
// 排除 manifest.json / checksums.json 自身与目录条目(以 "/" 结尾)。
|
|
238
|
-
const tableKeys = new Set(Object.keys(table));
|
|
239
|
-
const extraEntries = archive.names().filter((name) => name !== MANIFEST_FILE && name !== CHECKSUMS_FILE
|
|
240
|
-
&& !name.endsWith('/') && !tableKeys.has(name));
|
|
241
|
-
if (extraEntries.length > 0) {
|
|
242
|
-
zipWarnings.push(this.msg('import.extraEntries', {
|
|
243
|
-
entries: extraEntries.map((e) => `"${e}"`).join(', '),
|
|
244
|
-
}));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
203
|
+
const integrity = await scanArchiveIntegrity(archive, this.msg);
|
|
204
|
+
if (integrity.failure !== undefined)
|
|
205
|
+
throw new Error(integrity.failure);
|
|
206
|
+
zipWarnings.push(...integrity.warnings);
|
|
207
|
+
const { checksums, unverified } = integrity;
|
|
247
208
|
// 5. schema 版本判定(集中):过新(isTooNew)或过旧(低于 MIN_SUPPORTED)→ 一律硬失败(行为不变)
|
|
248
209
|
if (isTooNew(manifest.schemaVersion) || !isSupported(manifest.schemaVersion)) {
|
|
249
210
|
throw new Error(this.msg('import.schemaUnsupported', { version: describeVersion(manifest.schemaVersion) }));
|
|
@@ -265,7 +226,7 @@ export class Analyzer {
|
|
|
265
226
|
zipWarnings.push(this.msg('import.executableWarning', { name }));
|
|
266
227
|
}
|
|
267
228
|
}
|
|
268
|
-
const bundle = { archive, manifest, checksums, zipWarnings, migrationWarnings };
|
|
229
|
+
const bundle = { archive, manifest, checksums, unverifiedEntries: unverified, zipWarnings, migrationWarnings };
|
|
269
230
|
// 身份读得到才入实例缓存(与跨请求缓存同一判据:不拿未知身份签结果)。
|
|
270
231
|
if (identity !== null)
|
|
271
232
|
this.bundleCache.set(zipPath, { stamp: identity.stamp, bundle });
|
|
@@ -493,6 +454,9 @@ export class Analyzer {
|
|
|
493
454
|
}
|
|
494
455
|
// 会话格式体检只跑一次:告警、计划项标记、ImportAnalysis 摘要共用(探针要解首帧,别重复跑)。
|
|
495
456
|
const sessionFormats = this.resolveSessionFormats(sections);
|
|
457
|
+
// SEC-05:会落盘的文件分区条目必须在校验表内(表外条目此前照样被原样写进目标目录)。
|
|
458
|
+
// 阻断结果就在计划项自己身上(kind:'Error' + description),不另存一份名单(单一事实)。
|
|
459
|
+
applyUnverifiedFileBlocks(bundle.unverifiedEntries, adapterItems, this.fileCollectionIds(), this.extraEntryExemptSections, this.msg);
|
|
496
460
|
return {
|
|
497
461
|
...bundle,
|
|
498
462
|
sections,
|
|
@@ -533,7 +497,13 @@ export class Analyzer {
|
|
|
533
497
|
const { manifest, zipWarnings } = bundle;
|
|
534
498
|
const analyzed = await this.analyzeBundle(bundle);
|
|
535
499
|
const errors = [...analyzed.adapterIssues];
|
|
536
|
-
|
|
500
|
+
// SEC-05:未覆盖条目(表外 + 校验表缺失时的全部文件分区条目)≠ 被篡改 —— 逐条 Error 已保证零写入,
|
|
501
|
+
// 不再把整包判死。全仓只此一条 warning(RV1-F11:与 scanArchiveIntegrity 的 import.extraEntries 合并);
|
|
502
|
+
// 名单取**全部**未覆盖条目而非「被阻断」子集:豁免分区(历史包兼容)不阻断但仍必须被说出来。
|
|
503
|
+
const blockedCount = analyzed.adapterItems.filter((i) => i.detail === UNVERIFIED_FILE_ENTRY_CODE).length;
|
|
504
|
+
const unverifiedNotice = analyzed.unverifiedEntries.length > 0
|
|
505
|
+
? [unverifiedNoticeText(this.msg, analyzed.unverifiedEntries, blockedCount)] : [];
|
|
506
|
+
const warnings = [...zipWarnings, ...bundle.migrationWarnings, ...analyzed.sectionWarnings, ...unverifiedNotice];
|
|
537
507
|
warnings.push(...this.secretsPayloadWarnings(manifest, opts.decryptedCredentials));
|
|
538
508
|
if (!canImport(manifest.schemaVersion)) {
|
|
539
509
|
errors.push(this.msg('import.versionUnsupported', { version: describeVersion(manifest.schemaVersion) }));
|
|
@@ -915,14 +885,20 @@ export class Analyzer {
|
|
|
915
885
|
message: outcome.executed.message ?? null,
|
|
916
886
|
});
|
|
917
887
|
}
|
|
918
|
-
// 仅硬失败计入 anyFailed(warning 属非致命:目标不可达等,不触发回滚,§34.17
|
|
919
|
-
|
|
888
|
+
// 仅硬失败计入 anyFailed(warning 属非致命:目标不可达等,不触发回滚,§34.17)。
|
|
889
|
+
// 「硬失败」= 真的尝试过写、并且失败了(applyOne/adapter 抛错、applyItem 返回 failed、写一半的
|
|
890
|
+
// 目标不可达)。执行前就被策略拦下的项(kind 'Error':安全白名单 / SEC-05 未覆盖条目)**根本没
|
|
891
|
+
// 碰目标** —— 它连一个字节都没写,把它算成失败只会让 rollbackOnError(IE-02 缺省 true)在
|
|
892
|
+
// 「本来能导入的部分」上做一次整笔回滚,等于用安全策略制造数据损失;它也没留下半套状态可回滚。
|
|
893
|
+
// 因此这类预判阻断只记账(executed 里 status 'failed',用户可见),不触发回滚、不中断后续条目。
|
|
894
|
+
const hardFailed = outcome.executed.status === 'failed' && item.kind !== 'Error';
|
|
895
|
+
if (hardFailed)
|
|
920
896
|
anyFailed = true;
|
|
921
897
|
if (outcome.needsRestart)
|
|
922
898
|
needsRestart = true;
|
|
923
899
|
if (outcome.warning)
|
|
924
900
|
warnings.push(outcome.warning);
|
|
925
|
-
if (opts.rollbackOnError &&
|
|
901
|
+
if (opts.rollbackOnError && hardFailed)
|
|
926
902
|
break;
|
|
927
903
|
}
|
|
928
904
|
// 分区收尾(issue #45 ④):会话位置护栏等必须等本分区全部写完才做的动作。
|
|
@@ -1334,7 +1310,7 @@ export class Analyzer {
|
|
|
1334
1310
|
// analyzer 只保留三段式引擎本体;路径重定基/映射、schema 迁移校验、逐路径 issue、凭据计划项、
|
|
1335
1311
|
// 墓碑过滤、journal 上报这些**纯计算**都搬去 `./import-planning.ts`(本文件 1824 → 约 1490 行)。
|
|
1336
1312
|
// 依赖方向单行:analyzer → import-planning,后者绝不 import 本文件(避免第二个入口环)。
|
|
1337
|
-
import { applyMappingsToSections, applyTombstoneFilter, buildCredentialPlanItems, detectPathIssues, mergePathMappings, rebaseMapping, reportRollbackToJournal, runSchemaMigration, sessionUnitVersion, usableSecretValue,
|
|
1313
|
+
import { applyMappingsToSections, applyTombstoneFilter, buildCredentialPlanItems, detectPathIssues, mergePathMappings, rebaseMapping, reportRollbackToJournal, runSchemaMigration, sessionUnitVersion, usableSecretValue, } from './import-planning.js';
|
|
1338
1314
|
// 对外导入面保持不变(core/index.ts 与既有测试按 './analyzer.ts' 取这三个名字)。
|
|
1339
1315
|
export { applyMappingsToSections, rebaseMapping, runSchemaMigration } from './import-planning.js';
|
|
1340
1316
|
//# sourceMappingURL=analyzer.js.map
|
package/lib/core/backup.d.ts
CHANGED
|
@@ -145,6 +145,28 @@ export type PruneSelector = (metas: ReadonlyArray<{
|
|
|
145
145
|
* 行为等价于改造前(安全侧:宁可少删,绝不因缺实现而多删)。
|
|
146
146
|
*/
|
|
147
147
|
export declare const fallbackPruneSelector: PruneSelector;
|
|
148
|
+
export declare const PRE_RESTORE_KEEP_ATTEMPTS = 5;
|
|
149
|
+
export declare const PRE_RESTORE_KEEP_DAYS = 30;
|
|
150
|
+
/** 一条反悔副本 attempt 的淘汰计划(删除**前**就进 PrunePlan,供审计与界面展示)。 */
|
|
151
|
+
export interface PrunePreRestoreDrop {
|
|
152
|
+
snapshotId: string;
|
|
153
|
+
attempt: string;
|
|
154
|
+
/** count = 超出份数上限;over-age = 超过保留天数 */
|
|
155
|
+
reason: 'count' | 'over-age';
|
|
156
|
+
/** 该 attempt 目录里的文件数(用户能看出「删了多少份原文」) */
|
|
157
|
+
files: number;
|
|
158
|
+
}
|
|
159
|
+
/** 一轮保留清理的**计划**(在真正 rm 之前回调出去;也是 pruneNow 的返回值)。 */
|
|
160
|
+
export interface PrunePlan {
|
|
161
|
+
/** 将回收的快照 id(目录内的快照本体,不含 pre-restore 子树) */
|
|
162
|
+
removeSnapshots: string[];
|
|
163
|
+
/** 因存在反悔副本而**不**整目录删的快照 id(只删本体内容、留下 pre-restore/) */
|
|
164
|
+
keepPreRestoreFor: string[];
|
|
165
|
+
/** 反悔副本 attempt 级淘汰计划 */
|
|
166
|
+
dropPreRestore: PrunePreRestoreDrop[];
|
|
167
|
+
/** 计划删除的具体路径(相对快照根、'/' 分隔;审计「到底删了什么」) */
|
|
168
|
+
paths: string[];
|
|
169
|
+
}
|
|
148
170
|
export interface FileSnapshotStoreOptions {
|
|
149
171
|
/** 快照根目录(宿主决定,如 ~/.dsh/dsh-config-manager/snapshots) */
|
|
150
172
|
dir: string;
|
|
@@ -160,6 +182,8 @@ export interface FileSnapshotStoreOptions {
|
|
|
160
182
|
* 缺省 = 不记录(不改变 prune 行为、不污染核心引擎)。
|
|
161
183
|
*/
|
|
162
184
|
onPrune?: (removedIds: string[]) => void;
|
|
185
|
+
/** BK-05:保留清理的**计划**回调(在真正删除之前,best-effort;抛错不影响清理) */
|
|
186
|
+
onPrunePlan?: (plan: PrunePlan) => void;
|
|
163
187
|
/**
|
|
164
188
|
* m-retention:快照保留策略提供者(GFS 分层;可配置)。
|
|
165
189
|
* 缺省 = `SNAPSHOT_RETENTION_LIMIT`(= 既有的「保留最近 10 个」,行为不变)。
|
|
@@ -209,6 +233,18 @@ export declare class FileSnapshotStore implements SnapshotStore {
|
|
|
209
233
|
* Phase 4 F3:被 active/quarantine 未收敛 journal 引用的 snapshot(recovery 回滚点)
|
|
210
234
|
* 必须豁免——引用提供者(referencedSnapshotIds)返回的 id 绝不自动清理。 */
|
|
211
235
|
private prune;
|
|
236
|
+
/**
|
|
237
|
+
* BK-05:反悔副本(pre-restore)淘汰计划 —— 只看**份数与天数**,与快照本体的保留策略无关。
|
|
238
|
+
*
|
|
239
|
+
* 为什么单独一套:快照本体是「历史回滚点」,可被 GFS 策略批量回收;反悔副本是用户把
|
|
240
|
+
* 刚才那次恢复撤回去的**唯一**原文(不在 blobs、不进同步、不进导出),它只需要「别无限长」,
|
|
241
|
+
* 不需要也不该受 keepLast/keepMonthly 影响 —— 否则一次批量淘汰会把最近几次恢复的反悔点一起抹掉。
|
|
242
|
+
* attempt 名即恢复时间戳(restore.ts 的 preRestoreStamp,本地时间 yyyyMMdd-HHmmss-SSS[-序号]);
|
|
243
|
+
* 解析不出来回落目录 mtime(手工改名的残留也能被淘汰,不会永久占位)。
|
|
244
|
+
*/
|
|
245
|
+
private collectPreRestorePlan;
|
|
246
|
+
/** BK-05:手动触发一轮保留清理(与 save 后的自动清理同一实现)。 */
|
|
247
|
+
pruneNow(): Promise<PrunePlan>;
|
|
212
248
|
load(id: string): Promise<Snapshot>;
|
|
213
249
|
readBlob(id: string, blobPath: string): Promise<Uint8Array>;
|
|
214
250
|
/** 标记快照生命周期状态:重写 <dir>/<id>/snapshot.json(保留其余字段)。 */
|
package/lib/core/backup.js
CHANGED
|
@@ -474,6 +474,27 @@ export function selectPruneCandidates(metas, limit = SNAPSHOT_RETENTION_LIMIT) {
|
|
|
474
474
|
* 行为等价于改造前(安全侧:宁可少删,绝不因缺实现而多删)。
|
|
475
475
|
*/
|
|
476
476
|
export const fallbackPruneSelector = (metas, policy) => selectPruneCandidates(metas, policy.keepLast);
|
|
477
|
+
/* ---------------- BK-05:反悔副本(pre-restore)保留策略 ----------------
|
|
478
|
+
*
|
|
479
|
+
* 反悔副本 = 恢复链路在覆盖/删除前落的原文副本(restore.ts:888 起写 <snapshotDir>/pre-restore/<attempt>/)。
|
|
480
|
+
* 它与快照本体的生命周期**不是一回事**:本体可以按保留策略回收(历史回滚点),
|
|
481
|
+
* 反悔副本却是用户「把刚才这次恢复撤回去」的最后一份原文,整条链路上没有第二份
|
|
482
|
+
* (不在 blobs 里、不进同步、不进导出)。所以两套策略分开:
|
|
483
|
+
* - 快照本体:既有 RetentionPolicyLike(宿主可注入 GFS);
|
|
484
|
+
* - 反悔副本:只按「份数 + 天数」淘汰,常量在 core 内固定(无宿主可调项 —— 少了它等于没有反悔能力)。
|
|
485
|
+
*/
|
|
486
|
+
export const PRE_RESTORE_KEEP_ATTEMPTS = 5;
|
|
487
|
+
export const PRE_RESTORE_KEEP_DAYS = 30;
|
|
488
|
+
/** 反悔副本目录名(restore.ts 写入侧同一常量值;这里是读侧,绝不与写侧分叉) */
|
|
489
|
+
const PRE_RESTORE_DIR_NAME = 'pre-restore';
|
|
490
|
+
/** 解析反悔副本 attempt 目录名里的时间戳(本地时间 yyyyMMdd-HHmmss-SSS,可选 -N 去重后缀)。 */
|
|
491
|
+
function parsePreRestoreStamp(name) {
|
|
492
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(\d{3})(?:-\d+)?$/.exec(name);
|
|
493
|
+
if (m === null)
|
|
494
|
+
return null;
|
|
495
|
+
const at = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]), Number(m[7])).getTime();
|
|
496
|
+
return Number.isFinite(at) ? at : null;
|
|
497
|
+
}
|
|
477
498
|
/** 文件快照存储:<dir>/<id>/snapshot.json + <dir>/<id>/blobs/* */
|
|
478
499
|
export class FileSnapshotStore {
|
|
479
500
|
options;
|
|
@@ -565,6 +586,7 @@ export class FileSnapshotStore {
|
|
|
565
586
|
* Phase 4 F3:被 active/quarantine 未收敛 journal 引用的 snapshot(recovery 回滚点)
|
|
566
587
|
* 必须豁免——引用提供者(referencedSnapshotIds)返回的 id 绝不自动清理。 */
|
|
567
588
|
async prune() {
|
|
589
|
+
const emptyPlan = { removeSnapshots: [], keepPreRestoreFor: [], dropPreRestore: [], paths: [] };
|
|
568
590
|
const dir = this.options.dir;
|
|
569
591
|
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
570
592
|
// F3:被未收敛 journal 引用的快照**绝不自动清理**。判据是「读不到 ≠ 没有引用」——
|
|
@@ -577,7 +599,7 @@ export class FileSnapshotStore {
|
|
|
577
599
|
}
|
|
578
600
|
catch (err) {
|
|
579
601
|
this.options.onWarn?.(engineMsg('core.backup.pruneAbandoned', { reason: err instanceof Error ? err.message : String(err) }));
|
|
580
|
-
return;
|
|
602
|
+
return emptyPlan;
|
|
581
603
|
}
|
|
582
604
|
}
|
|
583
605
|
// m-retention:策略读取失败 → 回退缺省(绝不因策略提供者异常而误删/崩溃)
|
|
@@ -610,13 +632,66 @@ export class FileSnapshotStore {
|
|
|
610
632
|
// 损坏 / 非快照目录:跳过(与 listSnapshots 语义一致)
|
|
611
633
|
}
|
|
612
634
|
}
|
|
613
|
-
|
|
635
|
+
// --- BK-05:先出计划,再删 ---
|
|
636
|
+
// 快照本体与反悔副本是两套生命周期:本体按保留策略回收,pre-restore 按「份数 + 天数」淘汰。
|
|
637
|
+
// 计划必须在 rm **之前**成型并回调出去——删完再报等于审计拿不到原始状态。
|
|
638
|
+
const plan = {
|
|
639
|
+
removeSnapshots: [],
|
|
640
|
+
keepPreRestoreFor: [],
|
|
641
|
+
dropPreRestore: await this.collectPreRestorePlan(),
|
|
642
|
+
paths: [],
|
|
643
|
+
};
|
|
644
|
+
/** 每个待回收快照:目录名 → 要删的子项(保持 pre-restore 不动) */
|
|
645
|
+
const removals = [];
|
|
614
646
|
for (const id of selector(metas, policy)) {
|
|
615
647
|
const target = path.join(dir, id);
|
|
616
648
|
if (!target.startsWith(dir))
|
|
617
649
|
continue; // 越界 id 跳过(不删、不抛,同 save/readBlob 包含性约定)
|
|
650
|
+
const children = await fs.readdir(target).catch(() => []);
|
|
651
|
+
const hasPre = children.includes(PRE_RESTORE_DIR_NAME);
|
|
652
|
+
removals.push({ id, children, hasPre });
|
|
653
|
+
plan.removeSnapshots.push(id);
|
|
654
|
+
for (const child of children) {
|
|
655
|
+
if (child === PRE_RESTORE_DIR_NAME)
|
|
656
|
+
continue;
|
|
657
|
+
plan.paths.push(id + '/' + child);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for (const drop of plan.dropPreRestore)
|
|
661
|
+
plan.paths.push(drop.snapshotId + '/pre-restore/' + drop.attempt + '/');
|
|
662
|
+
this.options.onPrunePlan?.(plan);
|
|
663
|
+
if (plan.removeSnapshots.length === 0 && plan.dropPreRestore.length === 0)
|
|
664
|
+
return plan;
|
|
665
|
+
// 1) 回收快照本体:有反悔副本的快照**不整目录删**(否则唯一那份原文跟着消失),逐项删其余内容
|
|
666
|
+
const removedIds = [];
|
|
667
|
+
for (const removal of removals) {
|
|
668
|
+
const target = path.join(dir, removal.id);
|
|
669
|
+
for (const child of removal.children) {
|
|
670
|
+
if (child === PRE_RESTORE_DIR_NAME)
|
|
671
|
+
continue;
|
|
672
|
+
await fs.rm(path.join(target, child), { recursive: true, force: true });
|
|
673
|
+
}
|
|
674
|
+
if (removal.hasPre)
|
|
675
|
+
plan.keepPreRestoreFor.push(removal.id);
|
|
676
|
+
else
|
|
677
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
678
|
+
removedIds.push(removal.id);
|
|
679
|
+
}
|
|
680
|
+
// 2) 反悔副本 attempt 级淘汰(份数超限 / 超过保留天数)
|
|
681
|
+
for (const drop of plan.dropPreRestore) {
|
|
682
|
+
await fs.rm(path.join(dir, drop.snapshotId, PRE_RESTORE_DIR_NAME, drop.attempt), { recursive: true, force: true });
|
|
683
|
+
}
|
|
684
|
+
// 3) 收尾:快照本体已回收(无 snapshot.json)且反悔副本也空了 ⇒ 不留空壳目录
|
|
685
|
+
const touched = new Set([...plan.keepPreRestoreFor, ...plan.dropPreRestore.map((d) => d.snapshotId)]);
|
|
686
|
+
for (const id of touched) {
|
|
687
|
+
const target = path.join(dir, id);
|
|
688
|
+
const hasSnapshot = await fs.stat(path.join(target, 'snapshot.json')).catch(() => null) !== null;
|
|
689
|
+
if (hasSnapshot)
|
|
690
|
+
continue;
|
|
691
|
+
const left = await fs.readdir(path.join(target, PRE_RESTORE_DIR_NAME)).catch(() => null);
|
|
692
|
+
if (left !== null && left.length > 0)
|
|
693
|
+
continue;
|
|
618
694
|
await fs.rm(target, { recursive: true, force: true });
|
|
619
|
-
removedIds.push(id);
|
|
620
695
|
}
|
|
621
696
|
// Phase 6:自动保留清理历史回调(best-effort;不改变 prune 行为)
|
|
622
697
|
if (removedIds.length > 0) {
|
|
@@ -625,6 +700,53 @@ export class FileSnapshotStore {
|
|
|
625
700
|
}
|
|
626
701
|
catch { /* 历史回调失败不阻断 */ }
|
|
627
702
|
}
|
|
703
|
+
return plan;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* BK-05:反悔副本(pre-restore)淘汰计划 —— 只看**份数与天数**,与快照本体的保留策略无关。
|
|
707
|
+
*
|
|
708
|
+
* 为什么单独一套:快照本体是「历史回滚点」,可被 GFS 策略批量回收;反悔副本是用户把
|
|
709
|
+
* 刚才那次恢复撤回去的**唯一**原文(不在 blobs、不进同步、不进导出),它只需要「别无限长」,
|
|
710
|
+
* 不需要也不该受 keepLast/keepMonthly 影响 —— 否则一次批量淘汰会把最近几次恢复的反悔点一起抹掉。
|
|
711
|
+
* attempt 名即恢复时间戳(restore.ts 的 preRestoreStamp,本地时间 yyyyMMdd-HHmmss-SSS[-序号]);
|
|
712
|
+
* 解析不出来回落目录 mtime(手工改名的残留也能被淘汰,不会永久占位)。
|
|
713
|
+
*/
|
|
714
|
+
async collectPreRestorePlan() {
|
|
715
|
+
const dir = this.options.dir;
|
|
716
|
+
const drops = [];
|
|
717
|
+
const now = Date.now();
|
|
718
|
+
const maxAgeMs = PRE_RESTORE_KEEP_DAYS * 24 * 60 * 60 * 1000;
|
|
719
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
720
|
+
if (!entry.isDirectory())
|
|
721
|
+
continue;
|
|
722
|
+
const preRoot = path.join(dir, entry.name, PRE_RESTORE_DIR_NAME);
|
|
723
|
+
const attempts = await fs.readdir(preRoot, { withFileTypes: true }).catch(() => null);
|
|
724
|
+
if (attempts === null)
|
|
725
|
+
continue;
|
|
726
|
+
const rows = [];
|
|
727
|
+
for (const a of attempts) {
|
|
728
|
+
if (!a.isDirectory())
|
|
729
|
+
continue;
|
|
730
|
+
const at = parsePreRestoreStamp(a.name)
|
|
731
|
+
?? (await fs.stat(path.join(preRoot, a.name)).then((s) => s.mtimeMs).catch(() => now));
|
|
732
|
+
const names = await fs.readdir(path.join(preRoot, a.name), { recursive: true }).catch(() => []);
|
|
733
|
+
rows.push({ attempt: a.name, at, files: names.length });
|
|
734
|
+
}
|
|
735
|
+
rows.sort((x, y) => y.at - x.at); // 新 → 旧
|
|
736
|
+
rows.forEach((row, index) => {
|
|
737
|
+
if (index >= PRE_RESTORE_KEEP_ATTEMPTS) {
|
|
738
|
+
drops.push({ snapshotId: entry.name, attempt: row.attempt, reason: 'count', files: row.files });
|
|
739
|
+
}
|
|
740
|
+
else if (now - row.at > maxAgeMs) {
|
|
741
|
+
drops.push({ snapshotId: entry.name, attempt: row.attempt, reason: 'over-age', files: row.files });
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return drops;
|
|
746
|
+
}
|
|
747
|
+
/** BK-05:手动触发一轮保留清理(与 save 后的自动清理同一实现)。 */
|
|
748
|
+
async pruneNow() {
|
|
749
|
+
return this.prune();
|
|
628
750
|
}
|
|
629
751
|
async load(id) {
|
|
630
752
|
const raw = await fs.readFile(path.join(this.snapshotDir(id), 'snapshot.json'), 'utf8');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导入执行策略的**唯一缺省**(IE-02)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个零依赖模块:这个缺省必须被四处引用 —— 引擎 `src/core/importer.ts`、
|
|
5
|
+
* HTTP 路由 `src/routes/import.ts`、UI 向导 `src/ui/import-wizard.ts`、同步通道
|
|
6
|
+
* `src/sync/sync-engine.ts`。而 `src/ui/import-wizard.ts` 会被 `src/client/run-store.ts`
|
|
7
|
+
* 作为**值**导入并打进浏览器 bundle,所以它不能从 `src/core/importer.ts` 取这个值
|
|
8
|
+
* (那条路径经 Analyzer 拖进 node 内建与 ZIP 依赖)。本文件零依赖,两端都能安全引入。
|
|
9
|
+
*
|
|
10
|
+
* 语义:任一计划项失败立即整体回滚(安全侧,也就是 UI 文案「失败时整体回滚(推荐)」)。
|
|
11
|
+
* 要「单项失败如实记录并继续其余项」必须显式传 `false`,不再靠各入口各写一份缺省。
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_ROLLBACK_ON_ERROR = true;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导入执行策略的**唯一缺省**(IE-02)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个零依赖模块:这个缺省必须被四处引用 —— 引擎 `src/core/importer.ts`、
|
|
5
|
+
* HTTP 路由 `src/routes/import.ts`、UI 向导 `src/ui/import-wizard.ts`、同步通道
|
|
6
|
+
* `src/sync/sync-engine.ts`。而 `src/ui/import-wizard.ts` 会被 `src/client/run-store.ts`
|
|
7
|
+
* 作为**值**导入并打进浏览器 bundle,所以它不能从 `src/core/importer.ts` 取这个值
|
|
8
|
+
* (那条路径经 Analyzer 拖进 node 内建与 ZIP 依赖)。本文件零依赖,两端都能安全引入。
|
|
9
|
+
*
|
|
10
|
+
* 语义:任一计划项失败立即整体回滚(安全侧,也就是 UI 文案「失败时整体回滚(推荐)」)。
|
|
11
|
+
* 要「单项失败如实记录并继续其余项」必须显式传 `false`,不再靠各入口各写一份缺省。
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_ROLLBACK_ON_ERROR = true;
|
|
14
|
+
//# sourceMappingURL=import-defaults.js.map
|
package/lib/core/importer.d.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { ZipArchive, ZipSafetyLimits } from '../utils/zip.ts';
|
|
12
12
|
import type { MsgFunc } from './messages.ts';
|
|
13
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from './import-defaults.ts';
|
|
14
|
+
/**
|
|
15
|
+
* 缺省值的规范定义在 `./import-defaults.ts`(零依赖,UI 向导与浏览器 bundle 也引入它);
|
|
16
|
+
* 这里再导出一份,宿主侧调用方(路由、同步通道、CLI)可以从引擎门面拿,不必记两个路径。
|
|
17
|
+
*/
|
|
18
|
+
export { DEFAULT_ROLLBACK_ON_ERROR };
|
|
13
19
|
import type { PlanItemProgress } from './analyzer.ts';
|
|
14
20
|
import type { BootSafetyReport } from './boot-safety.ts';
|
|
15
21
|
import type { ConfigAdapter, HostContext, ImportAnalysis, ImportDecisions, ImportPlan, ImportResult, SessionFormatProbeResult, SnapshotStore, TransactionSnapshotContext } from './types.ts';
|
|
@@ -38,7 +44,10 @@ export interface ExecuteOptions {
|
|
|
38
44
|
secretInputs?: Record<string, string>;
|
|
39
45
|
/** 加密备份解密结果(仅内存;解密必须经 m4 encryption provider) */
|
|
40
46
|
decryptedCredentials?: Map<string, string>;
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* 任一项失败立即整体回滚。缺省 = `DEFAULT_ROLLBACK_ON_ERROR`(`./import-defaults.ts`,全仓唯一一份);
|
|
49
|
+
* 要「单项失败如实记录并继续其余项」必须显式传 `false`。
|
|
50
|
+
*/
|
|
42
51
|
rollbackOnError?: boolean;
|
|
43
52
|
/** m1:每完成一个计划项的进度回调(透传给 Analyzer;不传则无埋点) */
|
|
44
53
|
onItem?: (info: PlanItemProgress) => void;
|
package/lib/core/importer.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { Analyzer } from './analyzer.js';
|
|
2
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from './import-defaults.js';
|
|
3
|
+
/**
|
|
4
|
+
* 缺省值的规范定义在 `./import-defaults.ts`(零依赖,UI 向导与浏览器 bundle 也引入它);
|
|
5
|
+
* 这里再导出一份,宿主侧调用方(路由、同步通道、CLI)可以从引擎门面拿,不必记两个路径。
|
|
6
|
+
*/
|
|
7
|
+
export { DEFAULT_ROLLBACK_ON_ERROR };
|
|
2
8
|
export class Importer {
|
|
3
9
|
analyzer;
|
|
4
10
|
constructor(opts) {
|
|
@@ -26,7 +32,7 @@ export class Importer {
|
|
|
26
32
|
confirm: opts.confirm,
|
|
27
33
|
secretInputs: opts.secretInputs,
|
|
28
34
|
decryptedCredentials: opts.decryptedCredentials,
|
|
29
|
-
rollbackOnError: opts.rollbackOnError,
|
|
35
|
+
rollbackOnError: opts.rollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR,
|
|
30
36
|
onItem: opts.onItem,
|
|
31
37
|
onItemStart: opts.onItemStart,
|
|
32
38
|
onLog: opts.onLog,
|
package/lib/core/messages.d.ts
CHANGED
|
@@ -59,6 +59,7 @@ export declare const zh: {
|
|
|
59
59
|
readonly 'import.unsupportedSectionVersion': '分区 {section} 的数据版本 {version} 高于本版本支持的 1(已跳过该分区)';
|
|
60
60
|
readonly 'import.checksumsMissing': '备份未提供完整性校验表,全部条目未被校验';
|
|
61
61
|
readonly 'import.extraEntries': 'ZIP 内含未登记进校验表的条目: {entries}(未被校验)';
|
|
62
|
+
readonly 'import.unverifiedFileEntry': '以下条目不在校验表内,无法校验,已逐条阻断写入(其余条目照常导入): {entries}';
|
|
62
63
|
readonly 'import.undeclaredSectionContent': '备份包含未声明的分区内容(清单里标为不包含,这些条目不会被导入): {sections};示例条目: {entries}';
|
|
63
64
|
readonly 'import.secretsPayloadEmpty': '备份声明携带凭据值,但解出的凭据清单为空:这不是「没有凭据」,而是没读到(请确认备份密码与导出机的凭据库布局)';
|
|
64
65
|
readonly 'import.migrated': '备份 schema 已从 v{from} 迁移到 v{to}';
|
|
@@ -329,6 +330,7 @@ export declare const zh: {
|
|
|
329
330
|
readonly 'adapter.workspaceSessionsOutsideBundle': '另有 {count} 个会话不在此备份包内(本次未导入其数据),未登记;目标机本来就有这些会话则不受影响';
|
|
330
331
|
readonly 'adapter.workspaceSessionsManual': '本机工作区服务不支持会话登记,{count} 个会话未登记:启动 DSH 后可在设置里重新导入,或用离线修复工具 sessions repair';
|
|
331
332
|
readonly 'adapter.workspaceSessionsOwnerMissing': '找不到对应的目标工作区记录,{count} 个会话未登记:请确认备份里包含该工作区(导出会话时会自动连带其工作区)';
|
|
333
|
+
readonly 'adapter.selfImportBlocked': '包内含 {path}(同步目标配置):已拒绝写入,本机同步目标保持不变';
|
|
332
334
|
readonly 'adapter.providerMissing': '导入数据缺少 provider {route}';
|
|
333
335
|
readonly 'adapter.namespaceMissing': '导入数据缺少 namespace {ref}';
|
|
334
336
|
readonly 'adapter.validate.object': '{subject} 数据必须是对象';
|
|
@@ -375,6 +377,7 @@ export declare const zh: {
|
|
|
375
377
|
readonly 'sync.changesSummary': 'compatibility={compatibility},共 {count} 项变更';
|
|
376
378
|
readonly 'sync.configWhitespace': '仓库地址不能包含空白字符';
|
|
377
379
|
readonly 'sync.configUnparseable': '无法解析仓库地址: {url}';
|
|
380
|
+
readonly 'sync.configPrivateHost': '仓库地址指向本机或内网地址,不作为同步目标: {url}';
|
|
378
381
|
readonly 'sync.configUserinfo': '请勿在仓库地址中包含用户名/密码(认证 token 请使用凭据字段,不会拼入地址)';
|
|
379
382
|
readonly 'sync.state.notObject': 'sync-state.json 损坏:必须是对象';
|
|
380
383
|
readonly 'sync.state.schemaUnsupported': 'sync-state.json schemaVersion 不支持: {version}(期望 {expected})';
|
|
@@ -427,6 +430,7 @@ export declare const zh: {
|
|
|
427
430
|
readonly 'sync.webdav.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}';
|
|
428
431
|
readonly 'sync.pruneListFailed': '远端快照裁剪失败:无法列出远端快照,{reason}';
|
|
429
432
|
readonly 'sync.pruneDeleteFailed': '远端快照裁剪失败:删除快照 {id} 失败,{reason}';
|
|
433
|
+
readonly 'sync.gcSkipped': '本轮已放弃回收远端孤立文件({code}):本轮不做回收,快照本身不受影响';
|
|
430
434
|
readonly 'market.invalidItemId': '非法市场条目 id: {id}(仅允许字母数字开头,字符限 . _ -)';
|
|
431
435
|
readonly 'market.urlWhitespace': '市场仓库地址不能包含空白字符';
|
|
432
436
|
readonly 'market.urlUnparseable': '无法解析市场仓库地址: {url}';
|
package/lib/core/messages.js
CHANGED
|
@@ -44,6 +44,7 @@ export const zh = {
|
|
|
44
44
|
'import.unsupportedSectionVersion': '分区 {section} 的数据版本 {version} 高于本版本支持的 1(已跳过该分区)',
|
|
45
45
|
'import.checksumsMissing': '备份未提供完整性校验表,全部条目未被校验',
|
|
46
46
|
'import.extraEntries': 'ZIP 内含未登记进校验表的条目: {entries}(未被校验)',
|
|
47
|
+
'import.unverifiedFileEntry': '以下条目不在校验表内,无法校验,已逐条阻断写入(其余条目照常导入): {entries}',
|
|
47
48
|
'import.undeclaredSectionContent': '备份包含未声明的分区内容(清单里标为不包含,这些条目不会被导入): {sections};示例条目: {entries}',
|
|
48
49
|
'import.secretsPayloadEmpty': '备份声明携带凭据值,但解出的凭据清单为空:这不是「没有凭据」,而是没读到(请确认备份密码与导出机的凭据库布局)',
|
|
49
50
|
'import.migrated': '备份 schema 已从 v{from} 迁移到 v{to}',
|
|
@@ -321,6 +322,7 @@ export const zh = {
|
|
|
321
322
|
'adapter.workspaceSessionsOutsideBundle': '另有 {count} 个会话不在此备份包内(本次未导入其数据),未登记;目标机本来就有这些会话则不受影响',
|
|
322
323
|
'adapter.workspaceSessionsManual': '本机工作区服务不支持会话登记,{count} 个会话未登记:启动 DSH 后可在设置里重新导入,或用离线修复工具 sessions repair',
|
|
323
324
|
'adapter.workspaceSessionsOwnerMissing': '找不到对应的目标工作区记录,{count} 个会话未登记:请确认备份里包含该工作区(导出会话时会自动连带其工作区)',
|
|
325
|
+
'adapter.selfImportBlocked': '包内含 {path}(同步目标配置):已拒绝写入,本机同步目标保持不变',
|
|
324
326
|
'adapter.providerMissing': '导入数据缺少 provider {route}',
|
|
325
327
|
'adapter.namespaceMissing': '导入数据缺少 namespace {ref}',
|
|
326
328
|
// ---------- 适配器:结构校验(validate) ----------
|
|
@@ -369,6 +371,7 @@ export const zh = {
|
|
|
369
371
|
'sync.changesSummary': 'compatibility={compatibility},共 {count} 项变更',
|
|
370
372
|
'sync.configWhitespace': '仓库地址不能包含空白字符',
|
|
371
373
|
'sync.configUnparseable': '无法解析仓库地址: {url}',
|
|
374
|
+
'sync.configPrivateHost': '仓库地址指向本机或内网地址,不作为同步目标: {url}',
|
|
372
375
|
'sync.configUserinfo': '请勿在仓库地址中包含用户名/密码(认证 token 请使用凭据字段,不会拼入地址)',
|
|
373
376
|
'sync.state.notObject': 'sync-state.json 损坏:必须是对象',
|
|
374
377
|
'sync.state.schemaUnsupported': 'sync-state.json schemaVersion 不支持: {version}(期望 {expected})',
|
|
@@ -428,6 +431,7 @@ export const zh = {
|
|
|
428
431
|
'sync.webdav.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}',
|
|
429
432
|
'sync.pruneListFailed': '远端快照裁剪失败:无法列出远端快照,{reason}',
|
|
430
433
|
'sync.pruneDeleteFailed': '远端快照裁剪失败:删除快照 {id} 失败,{reason}',
|
|
434
|
+
'sync.gcSkipped': '本轮已放弃回收远端孤立文件({code}):本轮不做回收,快照本身不受影响',
|
|
431
435
|
// ---------- 配置市场(m-market:只读公开仓库目录读取 + 校验) ----------
|
|
432
436
|
'market.invalidItemId': '非法市场条目 id: {id}(仅允许字母数字开头,字符限 . _ -)',
|
|
433
437
|
'market.urlWhitespace': '市场仓库地址不能包含空白字符',
|
|
@@ -537,6 +541,7 @@ export const en = {
|
|
|
537
541
|
'import.unsupportedSectionVersion': 'Section {section} data version {version} is higher than the supported version 1 (section skipped)',
|
|
538
542
|
'import.checksumsMissing': 'Backup provides no integrity checksum table; no entry was verified',
|
|
539
543
|
'import.extraEntries': 'ZIP contains entries not registered in the checksum table: {entries} (not verified)',
|
|
544
|
+
'import.unverifiedFileEntry': 'These entries are not covered by the checksum table and cannot be verified, so each was blocked (the rest import normally): {entries}',
|
|
540
545
|
'import.undeclaredSectionContent': 'Backup contains content for undeclared sections (not marked as included; these entries will not be imported): {sections}; sample entries: {entries}',
|
|
541
546
|
'import.secretsPayloadEmpty': 'The backup declares embedded secret values, but no credential ref could be read out of it: this is not "no credentials", it means they were not read (check the backup password and the source machine credential-store layout)',
|
|
542
547
|
'import.migrated': 'Backup schema migrated from v{from} to v{to}',
|
|
@@ -814,6 +819,7 @@ export const en = {
|
|
|
814
819
|
'adapter.workspaceSessionsOutsideBundle': 'A further {count} session(s) are not part of this backup (their data was not imported) and stay unregistered; sessions that already exist on this machine are unaffected',
|
|
815
820
|
'adapter.workspaceSessionsManual': 'The host workspace service cannot attach sessions; {count} session(s) are unregistered: re-import from Settings once DSH is up, or use the offline tool: sessions repair',
|
|
816
821
|
'adapter.workspaceSessionsOwnerMissing': 'No matching target workspace record, {count} session(s) unregistered: make sure the backup contains that workspace (exporting sessions now carries their workspaces automatically)',
|
|
822
|
+
'adapter.selfImportBlocked': 'Package contains {path} (sync target config): write refused, local sync target unchanged',
|
|
817
823
|
'adapter.providerMissing': 'Import data is missing provider {route}',
|
|
818
824
|
'adapter.namespaceMissing': 'Import data is missing namespace {ref}',
|
|
819
825
|
// ---------- adapters: structural validation ----------
|
|
@@ -862,6 +868,7 @@ export const en = {
|
|
|
862
868
|
'sync.changesSummary': 'compatibility={compatibility}, {count} change(s)',
|
|
863
869
|
'sync.configWhitespace': 'Repository URL must not contain whitespace',
|
|
864
870
|
'sync.configUnparseable': 'Cannot parse repository URL: {url}',
|
|
871
|
+
'sync.configPrivateHost': 'Repository URL points at this machine or a private network, so it is not accepted as a sync target: {url}',
|
|
865
872
|
'sync.configUserinfo': 'Do not include a username/password in the repository URL (use the credential field for the auth token; it is never embedded in the URL)',
|
|
866
873
|
'sync.state.notObject': 'sync-state.json is corrupt: must be an object',
|
|
867
874
|
'sync.state.schemaUnsupported': 'sync-state.json schemaVersion not supported: {version} (expected {expected})',
|
|
@@ -917,6 +924,7 @@ export const en = {
|
|
|
917
924
|
'sync.webdav.snapshotInvalid': 'Snapshot {id} failed to parse or is structurally invalid: {err}',
|
|
918
925
|
'sync.pruneListFailed': 'Remote snapshot pruning failed: could not list remote snapshots, {reason}',
|
|
919
926
|
'sync.pruneDeleteFailed': 'Remote snapshot pruning failed: deleting snapshot {id} failed, {reason}',
|
|
927
|
+
'sync.gcSkipped': 'Skipped reclaiming remote orphan files this round ({code}); snapshots are unaffected',
|
|
920
928
|
// ---------- config marketplace (m-market: read-only public repo directory read + validation) ----------
|
|
921
929
|
'market.invalidItemId': 'Invalid marketplace item id: {id} (must start with an alphanumeric; only . _ - allowed)',
|
|
922
930
|
'market.urlWhitespace': 'Marketplace repository URL must not contain whitespace',
|
package/lib/core/model-tools.js
CHANGED
|
@@ -51,14 +51,30 @@ function dateStamp(d = new Date()) {
|
|
|
51
51
|
function filterSectionIds(ids) {
|
|
52
52
|
return ids.filter((id) => SECTION_IDS.includes(id));
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* SEC-03:把「读侧被白名单丢弃的配置」翻成一句可执行的补充说明。
|
|
56
|
+
* 只挑本通道的 issue —— git 地址被拒不该出现在 webdav 的错误里。
|
|
57
|
+
*/
|
|
58
|
+
function describeConfigIssues(issues, channel) {
|
|
59
|
+
const own = issues.filter((issue) => issue.channel === channel);
|
|
60
|
+
if (own.length === 0)
|
|
61
|
+
return '';
|
|
62
|
+
return own.map((issue) => `config issue ${issue.code} (${issue.channel}): ${issue.message}`).join('; ');
|
|
63
|
+
}
|
|
54
64
|
/** 解析同步引擎:channel 缺省取已配置的活动通道(git/webdav),未配置则抛可操作错误。 */
|
|
55
65
|
async function resolveEngine(deps, channel) {
|
|
66
|
+
// SEC-03:两处读取都收集 issues —— 否则「地址被安全校验拒掉」会被报成
|
|
67
|
+
// 「sync-config.json 缺失或损坏」,让模型/用户反复重填同一个被拒地址。
|
|
68
|
+
const issues = [];
|
|
56
69
|
// 未显式指定通道 → 取已配置的活动通道(唯一判定口径 parseSyncChannel;缺省 git)
|
|
57
|
-
const full = channel === undefined ? await readFullSyncConfig(deps.syncDir) : null;
|
|
70
|
+
const full = channel === undefined ? await readFullSyncConfig(deps.syncDir, issues) : null;
|
|
58
71
|
const ch = channel ?? parseSyncChannel(full?.transport) ?? 'git';
|
|
59
|
-
const cfg = await readSyncConfigFor(deps.syncDir, ch);
|
|
72
|
+
const cfg = await readSyncConfigFor(deps.syncDir, ch, issues);
|
|
60
73
|
if (cfg === null) {
|
|
61
|
-
|
|
74
|
+
const base = engineMsg('core.modelTools.syncChannelNotConfigured', { channel: ch });
|
|
75
|
+
const detail = describeConfigIssues(issues, ch);
|
|
76
|
+
// 无 issue = 真的没配(保持原文案);有 issue = 说清成因,绝不吞掉
|
|
77
|
+
throw new Error(detail === '' ? base : `${base} — ${detail}`);
|
|
62
78
|
}
|
|
63
79
|
return { engine: deps.makeSyncEngine(cfg), channel: ch };
|
|
64
80
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export declare const name = "config-manager";
|
|
|
77
77
|
/** Services required before the engine can mount (present in every profile). */
|
|
78
78
|
export declare const inject: string[];
|
|
79
79
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
80
|
-
export declare const PLUGIN_VERSION = "0.
|
|
80
|
+
export declare const PLUGIN_VERSION = "0.3.0";
|
|
81
81
|
/**
|
|
82
82
|
* Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
|
|
83
83
|
* 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
|