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/src/core/analyzer.ts
CHANGED
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { sha256Hex } from '../utils/hashing.ts';
|
|
13
|
-
import { parseJsonSafe } from '../utils/json.ts';
|
|
14
13
|
import {
|
|
15
14
|
SECTION_FILE_PREFIXES, SECTION_IDS, SECTION_JSON_PATHS, isFileSection, validateSectionData,
|
|
16
15
|
} from '../schema/config.ts';
|
|
17
|
-
import {
|
|
16
|
+
import { MANIFEST_FILE, parseManifest } from '../schema/manifest.ts';
|
|
18
17
|
import {
|
|
19
18
|
CURRENT_SCHEMA_VERSION, canImport, describeVersion, isSupported, isTooNew, needsMigration,
|
|
20
19
|
} from '../schema/versions.ts';
|
|
@@ -23,6 +22,8 @@ import type { BootSafetyReport } from './boot-safety.ts';
|
|
|
23
22
|
import type { FilesSection, Manifest, SectionId } from '../schema/types.ts';
|
|
24
23
|
import { loadTombstones } from '../schema/tombstones.ts';
|
|
25
24
|
import { DEFAULT_SENSITIVE_RELS, restoreVaultFiles } from '../security/vault.ts';
|
|
25
|
+
import { applyUnverifiedFileBlocks, scanArchiveIntegrity, unverifiedNoticeText } from '../security/unverified-file-blocks.ts';
|
|
26
|
+
import { UNVERIFIED_FILE_ENTRY_CODE } from '../security/integrity.ts';
|
|
26
27
|
import { createSnapshot, fsForSection, planItemWritesTarget, resolveFileTarget, resolveFileTargetRel } from './backup.ts';
|
|
27
28
|
import { isCredentialConfigured } from './credential-status.ts';
|
|
28
29
|
import { rollback } from './rollback.ts';
|
|
@@ -107,12 +108,15 @@ export interface AnalyzerOptions {
|
|
|
107
108
|
* 缺省 = 不做会话格式体检(旧调用方零改动)。
|
|
108
109
|
*/
|
|
109
110
|
sessionFormatProbe?: (files: readonly { relativePath: string; data: Uint8Array }[]) => SessionFormatProbeResult;
|
|
111
|
+
/** SEC-05:按分区豁免「表外条目阻断」(缺省空 = 严格);判据见 security/unverified-file-blocks.ts */
|
|
112
|
+
extraEntryExemptSections?: readonly SectionId[];
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
interface Bundle {
|
|
113
116
|
archive: ZipArchive;
|
|
114
117
|
manifest: Manifest;
|
|
115
118
|
checksums: { ok: boolean; mismatches: string[]; missing: string[] };
|
|
119
|
+
unverifiedEntries: string[];
|
|
116
120
|
zipWarnings: string[];
|
|
117
121
|
/** G-06:schema 迁移链实际执行的步骤告警(`import.migrated`;CURRENT=MIN=1 时恒为空) */
|
|
118
122
|
migrationWarnings: string[];
|
|
@@ -227,6 +231,7 @@ export class Analyzer {
|
|
|
227
231
|
private readonly parseZipFn: (buf: Uint8Array, limits?: ZipSafetyLimits) => ZipArchive;
|
|
228
232
|
private readonly msg: MsgFunc;
|
|
229
233
|
private readonly sessionFormatProbe?: (files: readonly { relativePath: string; data: Uint8Array }[]) => SessionFormatProbeResult;
|
|
234
|
+
private readonly extraEntryExemptSections: readonly string[];
|
|
230
235
|
/**
|
|
231
236
|
* 会话内 bundle 缓存(zipPath → { 身份, 解析结果 })。
|
|
232
237
|
*
|
|
@@ -250,6 +255,7 @@ export class Analyzer {
|
|
|
250
255
|
this.parseZipFn = opts.parseZipOverride ?? parseZip;
|
|
251
256
|
this.msg = opts.msg ?? msgOf(opts.ctx);
|
|
252
257
|
this.sessionFormatProbe = opts.sessionFormatProbe;
|
|
258
|
+
this.extraEntryExemptSections = opts.extraEntryExemptSections ?? [];
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
/* ---------------- 第 1-6 步:ZIP 读入 → 安全解析 → manifest → 完整性 → schema ---------------- */
|
|
@@ -297,55 +303,12 @@ export class Analyzer {
|
|
|
297
303
|
throw new Error(this.msg('import.manifestParseFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
// 4. 完整性(
|
|
306
|
+
// 4. 完整性(checksums.json 逐一 SHA-256)+ 4b 反向覆盖(表外条目):判据下沉在 security 侧。
|
|
301
307
|
const zipWarnings: string[] = [];
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// 两种形态对用户是同一件事(没有任何条目被校验),故共用同一条告警,不制造近重复文案。
|
|
307
|
-
const table = archive.has(CHECKSUMS_FILE)
|
|
308
|
-
? (parseJsonSafe(archive.readEntryText(CHECKSUMS_FILE)) as Record<string, string>)
|
|
309
|
-
: null;
|
|
310
|
-
if (table === null || Object.keys(table).length === 0) {
|
|
311
|
-
zipWarnings.push(this.msg('import.checksumsMissing'));
|
|
312
|
-
}
|
|
313
|
-
if (table !== null && Object.keys(table).length > 0) {
|
|
314
|
-
const entries = new Map<string, Uint8Array>();
|
|
315
|
-
for (const name of archive.names()) {
|
|
316
|
-
if (name === MANIFEST_FILE || name === CHECKSUMS_FILE) continue;
|
|
317
|
-
try {
|
|
318
|
-
// 异步解压:这是全仓**唯一**逐条解压全部条目的地方,`inflateRawSync` 会整段阻塞
|
|
319
|
-
// 事件循环(实测 29 MiB 归档 108 ms,大备份就是几秒的界面冻结)。判据与同步版逐字相同。
|
|
320
|
-
entries.set(name, await archive.readEntryAsync(name));
|
|
321
|
-
} catch {
|
|
322
|
-
entries.delete(name); // 损坏条目在完整性阶段即失败
|
|
323
|
-
checksums.ok = false;
|
|
324
|
-
checksums.mismatches.push(name);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
const result = await verifyAgainstTable(entries, table);
|
|
328
|
-
checksums = result;
|
|
329
|
-
if (!checksums.ok) {
|
|
330
|
-
throw new Error(this.msg('import.integrityFailed', {
|
|
331
|
-
entries: [...checksums.mismatches, ...checksums.missing].map((m) => `"${m}"`).join(', '),
|
|
332
|
-
}));
|
|
333
|
-
}
|
|
334
|
-
// 4b. G-04:反向完整性——ZIP 内**不在**校验表里的条目(未登记 ⇒ 未被校验)。
|
|
335
|
-
// verifyAgainstTable 只遍历表里的键,所以 ZIP 里多出的条目此前既不校验也不告知;
|
|
336
|
-
// 这里把它变成显式 warning(不阻断:未知 ZIP 条目不参与校验是格式 v1 的既定语义)。
|
|
337
|
-
// 排除 manifest.json / checksums.json 自身与目录条目(以 "/" 结尾)。
|
|
338
|
-
const tableKeys = new Set(Object.keys(table));
|
|
339
|
-
const extraEntries = archive.names().filter(
|
|
340
|
-
(name) => name !== MANIFEST_FILE && name !== CHECKSUMS_FILE
|
|
341
|
-
&& !name.endsWith('/') && !tableKeys.has(name),
|
|
342
|
-
);
|
|
343
|
-
if (extraEntries.length > 0) {
|
|
344
|
-
zipWarnings.push(this.msg('import.extraEntries', {
|
|
345
|
-
entries: extraEntries.map((e) => `"${e}"`).join(', '),
|
|
346
|
-
}));
|
|
347
|
-
}
|
|
348
|
-
}
|
|
308
|
+
const integrity = await scanArchiveIntegrity(archive, this.msg);
|
|
309
|
+
if (integrity.failure !== undefined) throw new Error(integrity.failure);
|
|
310
|
+
zipWarnings.push(...integrity.warnings);
|
|
311
|
+
const { checksums, unverified } = integrity;
|
|
349
312
|
|
|
350
313
|
// 5. schema 版本判定(集中):过新(isTooNew)或过旧(低于 MIN_SUPPORTED)→ 一律硬失败(行为不变)
|
|
351
314
|
if (isTooNew(manifest.schemaVersion) || !isSupported(manifest.schemaVersion)) {
|
|
@@ -371,7 +334,7 @@ export class Analyzer {
|
|
|
371
334
|
}
|
|
372
335
|
}
|
|
373
336
|
|
|
374
|
-
const bundle: Bundle = { archive, manifest, checksums, zipWarnings, migrationWarnings };
|
|
337
|
+
const bundle: Bundle = { archive, manifest, checksums, unverifiedEntries: unverified, zipWarnings, migrationWarnings };
|
|
375
338
|
// 身份读得到才入实例缓存(与跨请求缓存同一判据:不拿未知身份签结果)。
|
|
376
339
|
if (identity !== null) this.bundleCache.set(zipPath, { stamp: identity.stamp, bundle });
|
|
377
340
|
// 写入跨请求缓存:execute(refresh)也写 —— 它刚做过完整校验,这份结果对后续只读入口同样有效。
|
|
@@ -584,6 +547,12 @@ export class Analyzer {
|
|
|
584
547
|
// 会话格式体检只跑一次:告警、计划项标记、ImportAnalysis 摘要共用(探针要解首帧,别重复跑)。
|
|
585
548
|
const sessionFormats = this.resolveSessionFormats(sections);
|
|
586
549
|
|
|
550
|
+
// SEC-05:会落盘的文件分区条目必须在校验表内(表外条目此前照样被原样写进目标目录)。
|
|
551
|
+
// 阻断结果就在计划项自己身上(kind:'Error' + description),不另存一份名单(单一事实)。
|
|
552
|
+
applyUnverifiedFileBlocks(
|
|
553
|
+
bundle.unverifiedEntries, adapterItems, this.fileCollectionIds(), this.extraEntryExemptSections, this.msg,
|
|
554
|
+
);
|
|
555
|
+
|
|
587
556
|
return {
|
|
588
557
|
...bundle,
|
|
589
558
|
sections,
|
|
@@ -629,7 +598,13 @@ export class Analyzer {
|
|
|
629
598
|
const analyzed = await this.analyzeBundle(bundle);
|
|
630
599
|
|
|
631
600
|
const errors = [...analyzed.adapterIssues];
|
|
632
|
-
|
|
601
|
+
// SEC-05:未覆盖条目(表外 + 校验表缺失时的全部文件分区条目)≠ 被篡改 —— 逐条 Error 已保证零写入,
|
|
602
|
+
// 不再把整包判死。全仓只此一条 warning(RV1-F11:与 scanArchiveIntegrity 的 import.extraEntries 合并);
|
|
603
|
+
// 名单取**全部**未覆盖条目而非「被阻断」子集:豁免分区(历史包兼容)不阻断但仍必须被说出来。
|
|
604
|
+
const blockedCount = analyzed.adapterItems.filter((i) => i.detail === UNVERIFIED_FILE_ENTRY_CODE).length;
|
|
605
|
+
const unverifiedNotice = analyzed.unverifiedEntries.length > 0
|
|
606
|
+
? [unverifiedNoticeText(this.msg, analyzed.unverifiedEntries, blockedCount)] : [];
|
|
607
|
+
const warnings = [...zipWarnings, ...bundle.migrationWarnings, ...analyzed.sectionWarnings, ...unverifiedNotice];
|
|
633
608
|
warnings.push(...this.secretsPayloadWarnings(manifest, opts.decryptedCredentials));
|
|
634
609
|
if (!canImport(manifest.schemaVersion)) {
|
|
635
610
|
errors.push(this.msg('import.versionUnsupported', { version: describeVersion(manifest.schemaVersion) }));
|
|
@@ -1074,11 +1049,17 @@ export class Analyzer {
|
|
|
1074
1049
|
message: outcome.executed.message ?? null,
|
|
1075
1050
|
});
|
|
1076
1051
|
}
|
|
1077
|
-
// 仅硬失败计入 anyFailed(warning 属非致命:目标不可达等,不触发回滚,§34.17
|
|
1078
|
-
|
|
1052
|
+
// 仅硬失败计入 anyFailed(warning 属非致命:目标不可达等,不触发回滚,§34.17)。
|
|
1053
|
+
// 「硬失败」= 真的尝试过写、并且失败了(applyOne/adapter 抛错、applyItem 返回 failed、写一半的
|
|
1054
|
+
// 目标不可达)。执行前就被策略拦下的项(kind 'Error':安全白名单 / SEC-05 未覆盖条目)**根本没
|
|
1055
|
+
// 碰目标** —— 它连一个字节都没写,把它算成失败只会让 rollbackOnError(IE-02 缺省 true)在
|
|
1056
|
+
// 「本来能导入的部分」上做一次整笔回滚,等于用安全策略制造数据损失;它也没留下半套状态可回滚。
|
|
1057
|
+
// 因此这类预判阻断只记账(executed 里 status 'failed',用户可见),不触发回滚、不中断后续条目。
|
|
1058
|
+
const hardFailed = outcome.executed.status === 'failed' && item.kind !== 'Error';
|
|
1059
|
+
if (hardFailed) anyFailed = true;
|
|
1079
1060
|
if (outcome.needsRestart) needsRestart = true;
|
|
1080
1061
|
if (outcome.warning) warnings.push(outcome.warning);
|
|
1081
|
-
if (opts.rollbackOnError &&
|
|
1062
|
+
if (opts.rollbackOnError && hardFailed) break;
|
|
1082
1063
|
}
|
|
1083
1064
|
// 分区收尾(issue #45 ④):会话位置护栏等必须等本分区全部写完才做的动作。
|
|
1084
1065
|
// 「终止 + 回滚」例外:整笔马上要被回滚,再跑收尾纯属浪费且可能中途失败。
|
|
@@ -1493,7 +1474,7 @@ export class Analyzer {
|
|
|
1493
1474
|
import {
|
|
1494
1475
|
applyMappingsToSections, applyTombstoneFilter, buildCredentialPlanItems, detectPathIssues,
|
|
1495
1476
|
mergePathMappings, rebaseMapping, reportRollbackToJournal, runSchemaMigration, sessionUnitVersion,
|
|
1496
|
-
usableSecretValue,
|
|
1477
|
+
usableSecretValue,
|
|
1497
1478
|
} from './import-planning.ts';
|
|
1498
1479
|
|
|
1499
1480
|
// 对外导入面保持不变(core/index.ts 与既有测试按 './analyzer.ts' 取这三个名字)。
|
package/src/core/backup.test.ts
CHANGED
|
@@ -29,7 +29,12 @@ import fs from 'node:fs/promises';
|
|
|
29
29
|
import os from 'node:os';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
FileSnapshotStore, PRE_RESTORE_KEEP_ATTEMPTS, PRE_RESTORE_KEEP_DAYS,
|
|
34
|
+
createSnapshot, normalizeDshVersion, resolveFileTarget, verifySnapshot,
|
|
35
|
+
} from './backup.ts';
|
|
36
|
+
import type { PrunePlan } from './backup.ts';
|
|
37
|
+
import { existsSync } from 'node:fs';
|
|
33
38
|
import { rollback } from './rollback.ts';
|
|
34
39
|
import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
|
|
35
40
|
import type { ImportPlan, Snapshot, SnapshotStore } from './types.ts';
|
|
@@ -461,3 +466,136 @@ test('mcp 连接器:storages/mcp_connector.json 进快照为整文件条目,
|
|
|
461
466
|
assert.deepEqual(report.restored, ['mcp:' + rel], '回滚必须报告真正被还原的那一条');
|
|
462
467
|
assert.equal(new TextDecoder().decode(await ctx.fs.readFile(abs)), original);
|
|
463
468
|
});
|
|
469
|
+
|
|
470
|
+
/* ---------------- BK-05:prune 不得销毁反悔副本(pre-restore) ----------------
|
|
471
|
+
*
|
|
472
|
+
* 反悔副本 = 恢复链路在覆盖/删除前落的原文副本:<snapshotsDir>/<snapshotId>/pre-restore/<attempt>/(restore.ts:888)。
|
|
473
|
+
* 此前 prune 用 fs.rm(<snapshotsDir>/<id>, { recursive: true }) 整目录删 ⇒ 快照一被淘汰,用户最后一次
|
|
474
|
+
* 「反悔」的原文就跟着消失,而那份字节是**唯一**的(不在 blobs 里、不进同步、不进导出)。
|
|
475
|
+
* 现在的口径:快照本体照常回收,pre-restore 子树留下;它有自己的保留策略(数量 + 天数),
|
|
476
|
+
* 且删除前先把「要删什么」回调出去(onPrunePlan),不先删后报。
|
|
477
|
+
*/
|
|
478
|
+
|
|
479
|
+
/** 与 restore.ts 的 preRestoreStamp 同格式(本地时间 yyyyMMdd-HHmmss-SSS)。 */
|
|
480
|
+
function stamp(date: Date): string {
|
|
481
|
+
const p = (n: number, width = 2): string => String(n).padStart(width, '0');
|
|
482
|
+
return p(date.getFullYear(), 4) + p(date.getMonth() + 1) + p(date.getDate())
|
|
483
|
+
+ '-' + p(date.getHours()) + p(date.getMinutes()) + p(date.getSeconds())
|
|
484
|
+
+ '-' + p(date.getMilliseconds(), 3);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function plantPreRestore(
|
|
488
|
+
dir: string,
|
|
489
|
+
snapshotId: string,
|
|
490
|
+
attempt: string,
|
|
491
|
+
files: Record<string, string>,
|
|
492
|
+
): Promise<string> {
|
|
493
|
+
const pre = path.join(dir, snapshotId, 'pre-restore', attempt);
|
|
494
|
+
await fs.mkdir(pre, { recursive: true });
|
|
495
|
+
for (const [name, body] of Object.entries(files)) await fs.writeFile(path.join(pre, name), body, 'utf8');
|
|
496
|
+
return pre;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
test('BK-05/①:prune 淘汰旧快照时保留 pre-restore 反悔副本(原文逐字节还在),删什么先落计划', async (t) => {
|
|
500
|
+
const dir = await tmpDir(t, 'prune-prerestore');
|
|
501
|
+
const { ctx } = countingCtx();
|
|
502
|
+
await seedHostFiles(ctx);
|
|
503
|
+
const plans: PrunePlan[] = [];
|
|
504
|
+
/** 计划回调里快照本体还在吗 —— 证明「先出清单,再删」而不是删完再编 */
|
|
505
|
+
let snapshotJsonAliveAtPlanTime: boolean | null = null;
|
|
506
|
+
const store = new FileSnapshotStore({
|
|
507
|
+
dir,
|
|
508
|
+
retentionPolicy: () => ({ keepLast: 1, keepMonthly: 0, keepYearly: 0 }),
|
|
509
|
+
onPrunePlan: (plan) => {
|
|
510
|
+
plans.push(plan);
|
|
511
|
+
if (plan.removeSnapshots.length > 0) {
|
|
512
|
+
snapshotJsonAliveAtPlanTime = existsSync(path.join(dir, plan.removeSnapshots[0]!, 'snapshot.json'));
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const first = await createSnapshot({ ctx, plan: emptyPlan(), sourceZip: 'unit.zip', store, adapters: [] });
|
|
518
|
+
const attempt = stamp(new Date());
|
|
519
|
+
const pre = await plantPreRestore(dir, first.id, attempt, { '0001-settings.yaml': 'old-settings' });
|
|
520
|
+
const second = await createSnapshot({ ctx, plan: emptyPlan(), sourceZip: 'unit.zip', store, adapters: [] });
|
|
521
|
+
|
|
522
|
+
// 快照本体照常回收(否则保留清理等于失效)
|
|
523
|
+
await assert.rejects(fs.readFile(path.join(dir, first.id, 'snapshot.json'), 'utf8'), '旧快照的 snapshot.json 必须删掉');
|
|
524
|
+
await assert.rejects(fs.stat(path.join(dir, first.id, 'blobs')), '旧快照的 blobs 必须回收');
|
|
525
|
+
// 反悔副本一字不差
|
|
526
|
+
assert.equal(await fs.readFile(path.join(pre, '0001-settings.yaml'), 'utf8'), 'old-settings', '反悔副本必须原样保留');
|
|
527
|
+
assert.equal(existsSync(path.join(dir, second.id, 'snapshot.json')), true);
|
|
528
|
+
|
|
529
|
+
const plan = plans.at(-1)!;
|
|
530
|
+
assert.deepEqual(plan.removeSnapshots, [first.id], '计划里必须有「要删哪个快照」');
|
|
531
|
+
assert.ok(plan.keepPreRestoreFor.includes(first.id), '因反悔副本而不整目录删的快照要如实列出:' + JSON.stringify(plan));
|
|
532
|
+
assert.ok(plan.paths.includes(first.id + '/snapshot.json'), '计划要含具体路径(审计「到底删了什么」):' + JSON.stringify(plan.paths));
|
|
533
|
+
assert.equal(snapshotJsonAliveAtPlanTime, true, '计划必须在真正删除**之前**给出');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('BK-05/②:反悔副本自有保留策略 —— 每次恢复一个 attempt,只留最新 5 个', async (t) => {
|
|
537
|
+
const dir = await tmpDir(t, 'prune-prerestore-count');
|
|
538
|
+
const { ctx } = countingCtx();
|
|
539
|
+
await seedHostFiles(ctx);
|
|
540
|
+
const plans: PrunePlan[] = [];
|
|
541
|
+
const store = new FileSnapshotStore({ dir, onPrunePlan: (plan) => { plans.push(plan) } });
|
|
542
|
+
const snap = await createSnapshot({ ctx, plan: emptyPlan(), sourceZip: 'unit.zip', store, adapters: [] });
|
|
543
|
+
|
|
544
|
+
// 7 个 attempt(时间递增;每个 attempt 只装它那一次被覆盖的文件)
|
|
545
|
+
const base = Date.now() - 6 * 60 * 60 * 1000;
|
|
546
|
+
const names: string[] = [];
|
|
547
|
+
for (let i = 0; i < 7; i += 1) {
|
|
548
|
+
const name = stamp(new Date(base + i * 60 * 60 * 1000));
|
|
549
|
+
names.push(name);
|
|
550
|
+
await plantPreRestore(dir, snap.id, name, { '0001-settings.yaml': 'v' + i });
|
|
551
|
+
}
|
|
552
|
+
await store.pruneNow();
|
|
553
|
+
|
|
554
|
+
const left = (await fs.readdir(path.join(dir, snap.id, 'pre-restore'))).sort();
|
|
555
|
+
assert.deepEqual(left, names.slice(-PRE_RESTORE_KEEP_ATTEMPTS).sort(), '只留最新 ' + PRE_RESTORE_KEEP_ATTEMPTS + ' 个 attempt');
|
|
556
|
+
const plan = plans.at(-1)!;
|
|
557
|
+
const dropped = plan.dropPreRestore.map((d) => d.attempt).sort();
|
|
558
|
+
assert.deepEqual(dropped, names.slice(0, names.length - PRE_RESTORE_KEEP_ATTEMPTS).sort(), '被删的 attempt 必须逐条进计划');
|
|
559
|
+
assert.ok(plan.dropPreRestore.every((d) => d.reason === 'count'));
|
|
560
|
+
assert.ok(plan.dropPreRestore.every((d) => d.files === 1), '计划要带文件数(用户能看出删了多少)');
|
|
561
|
+
assert.equal(existsSync(path.join(dir, snap.id, 'snapshot.json')), true, '活着的快照本体绝不能被这轮清理碰到');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('BK-05/③:反悔副本超过保留天数 → 删;近期的不动', async (t) => {
|
|
565
|
+
const dir = await tmpDir(t, 'prune-prerestore-age');
|
|
566
|
+
const { ctx } = countingCtx();
|
|
567
|
+
await seedHostFiles(ctx);
|
|
568
|
+
const plans: PrunePlan[] = [];
|
|
569
|
+
const store = new FileSnapshotStore({ dir, onPrunePlan: (plan) => { plans.push(plan) } });
|
|
570
|
+
const snap = await createSnapshot({ ctx, plan: emptyPlan(), sourceZip: 'unit.zip', store, adapters: [] });
|
|
571
|
+
|
|
572
|
+
const oldAttempt = stamp(new Date(Date.now() - (PRE_RESTORE_KEEP_DAYS + 5) * 24 * 60 * 60 * 1000));
|
|
573
|
+
const freshAttempt = stamp(new Date(Date.now() - 60 * 60 * 1000));
|
|
574
|
+
await plantPreRestore(dir, snap.id, oldAttempt, { '0001-settings.yaml': 'ancient' });
|
|
575
|
+
await plantPreRestore(dir, snap.id, freshAttempt, { '0001-settings.yaml': 'fresh' });
|
|
576
|
+
await store.pruneNow();
|
|
577
|
+
|
|
578
|
+
const left = (await fs.readdir(path.join(dir, snap.id, 'pre-restore'))).sort();
|
|
579
|
+
assert.deepEqual(left, [freshAttempt], '超过 ' + PRE_RESTORE_KEEP_DAYS + ' 天的反悔副本该按天数淘汰');
|
|
580
|
+
assert.deepEqual(
|
|
581
|
+
plans.at(-1)!.dropPreRestore.map((d) => ({ attempt: d.attempt, reason: d.reason })),
|
|
582
|
+
[{ attempt: oldAttempt, reason: 'over-age' }],
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('BK-05/④:反悔副本全部过期后不留空壳目录(快照已不在 ⇒ 目录一起收掉)', async (t) => {
|
|
587
|
+
const dir = await tmpDir(t, 'prune-prerestore-shell');
|
|
588
|
+
const { ctx } = countingCtx();
|
|
589
|
+
await seedHostFiles(ctx);
|
|
590
|
+
const store = new FileSnapshotStore({
|
|
591
|
+
dir,
|
|
592
|
+
retentionPolicy: () => ({ keepLast: 1, keepMonthly: 0, keepYearly: 0 }),
|
|
593
|
+
});
|
|
594
|
+
const first = await createSnapshot({ ctx, plan: emptyPlan(), sourceZip: 'unit.zip', store, adapters: [] });
|
|
595
|
+
const oldAttempt = stamp(new Date(Date.now() - (PRE_RESTORE_KEEP_DAYS + 1) * 24 * 60 * 60 * 1000));
|
|
596
|
+
await plantPreRestore(dir, first.id, oldAttempt, { '0001-settings.yaml': 'ancient' });
|
|
597
|
+
await createSnapshot({ ctx, plan: emptyPlan(), sourceZip: 'unit.zip', store, adapters: [] });
|
|
598
|
+
|
|
599
|
+
assert.equal(existsSync(path.join(dir, first.id)), false, '快照没了 + 反悔副本也过期 ⇒ 不留空壳');
|
|
600
|
+
});
|
|
601
|
+
|
package/src/core/backup.ts
CHANGED
|
@@ -593,6 +593,53 @@ export type PruneSelector = (
|
|
|
593
593
|
export const fallbackPruneSelector: PruneSelector = (metas, policy) =>
|
|
594
594
|
selectPruneCandidates(metas, policy.keepLast);
|
|
595
595
|
|
|
596
|
+
/* ---------------- BK-05:反悔副本(pre-restore)保留策略 ----------------
|
|
597
|
+
*
|
|
598
|
+
* 反悔副本 = 恢复链路在覆盖/删除前落的原文副本(restore.ts:888 起写 <snapshotDir>/pre-restore/<attempt>/)。
|
|
599
|
+
* 它与快照本体的生命周期**不是一回事**:本体可以按保留策略回收(历史回滚点),
|
|
600
|
+
* 反悔副本却是用户「把刚才这次恢复撤回去」的最后一份原文,整条链路上没有第二份
|
|
601
|
+
* (不在 blobs 里、不进同步、不进导出)。所以两套策略分开:
|
|
602
|
+
* - 快照本体:既有 RetentionPolicyLike(宿主可注入 GFS);
|
|
603
|
+
* - 反悔副本:只按「份数 + 天数」淘汰,常量在 core 内固定(无宿主可调项 —— 少了它等于没有反悔能力)。
|
|
604
|
+
*/
|
|
605
|
+
export const PRE_RESTORE_KEEP_ATTEMPTS = 5;
|
|
606
|
+
export const PRE_RESTORE_KEEP_DAYS = 30;
|
|
607
|
+
/** 反悔副本目录名(restore.ts 写入侧同一常量值;这里是读侧,绝不与写侧分叉) */
|
|
608
|
+
const PRE_RESTORE_DIR_NAME = 'pre-restore';
|
|
609
|
+
|
|
610
|
+
/** 解析反悔副本 attempt 目录名里的时间戳(本地时间 yyyyMMdd-HHmmss-SSS,可选 -N 去重后缀)。 */
|
|
611
|
+
function parsePreRestoreStamp(name: string): number | null {
|
|
612
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(\d{3})(?:-\d+)?$/.exec(name);
|
|
613
|
+
if (m === null) return null;
|
|
614
|
+
const at = new Date(
|
|
615
|
+
Number(m[1]), Number(m[2]) - 1, Number(m[3]),
|
|
616
|
+
Number(m[4]), Number(m[5]), Number(m[6]), Number(m[7]),
|
|
617
|
+
).getTime();
|
|
618
|
+
return Number.isFinite(at) ? at : null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 一条反悔副本 attempt 的淘汰计划(删除**前**就进 PrunePlan,供审计与界面展示)。 */
|
|
622
|
+
export interface PrunePreRestoreDrop {
|
|
623
|
+
snapshotId: string;
|
|
624
|
+
attempt: string;
|
|
625
|
+
/** count = 超出份数上限;over-age = 超过保留天数 */
|
|
626
|
+
reason: 'count' | 'over-age';
|
|
627
|
+
/** 该 attempt 目录里的文件数(用户能看出「删了多少份原文」) */
|
|
628
|
+
files: number;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** 一轮保留清理的**计划**(在真正 rm 之前回调出去;也是 pruneNow 的返回值)。 */
|
|
632
|
+
export interface PrunePlan {
|
|
633
|
+
/** 将回收的快照 id(目录内的快照本体,不含 pre-restore 子树) */
|
|
634
|
+
removeSnapshots: string[];
|
|
635
|
+
/** 因存在反悔副本而**不**整目录删的快照 id(只删本体内容、留下 pre-restore/) */
|
|
636
|
+
keepPreRestoreFor: string[];
|
|
637
|
+
/** 反悔副本 attempt 级淘汰计划 */
|
|
638
|
+
dropPreRestore: PrunePreRestoreDrop[];
|
|
639
|
+
/** 计划删除的具体路径(相对快照根、'/' 分隔;审计「到底删了什么」) */
|
|
640
|
+
paths: string[];
|
|
641
|
+
}
|
|
642
|
+
|
|
596
643
|
export interface FileSnapshotStoreOptions {
|
|
597
644
|
/** 快照根目录(宿主决定,如 ~/.dsh/dsh-config-manager/snapshots) */
|
|
598
645
|
dir: string;
|
|
@@ -608,6 +655,8 @@ export interface FileSnapshotStoreOptions {
|
|
|
608
655
|
* 缺省 = 不记录(不改变 prune 行为、不污染核心引擎)。
|
|
609
656
|
*/
|
|
610
657
|
onPrune?: (removedIds: string[]) => void;
|
|
658
|
+
/** BK-05:保留清理的**计划**回调(在真正删除之前,best-effort;抛错不影响清理) */
|
|
659
|
+
onPrunePlan?: (plan: PrunePlan) => void;
|
|
611
660
|
/**
|
|
612
661
|
* m-retention:快照保留策略提供者(GFS 分层;可配置)。
|
|
613
662
|
* 缺省 = `SNAPSHOT_RETENTION_LIMIT`(= 既有的「保留最近 10 个」,行为不变)。
|
|
@@ -722,7 +771,8 @@ export class FileSnapshotStore implements SnapshotStore {
|
|
|
722
771
|
* 自动淘汰,只能手动删除(deleteSnapshot)。
|
|
723
772
|
* Phase 4 F3:被 active/quarantine 未收敛 journal 引用的 snapshot(recovery 回滚点)
|
|
724
773
|
* 必须豁免——引用提供者(referencedSnapshotIds)返回的 id 绝不自动清理。 */
|
|
725
|
-
private async prune(): Promise<
|
|
774
|
+
private async prune(): Promise<PrunePlan> {
|
|
775
|
+
const emptyPlan: PrunePlan = { removeSnapshots: [], keepPreRestoreFor: [], dropPreRestore: [], paths: [] };
|
|
726
776
|
const dir = this.options.dir;
|
|
727
777
|
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
728
778
|
// F3:被未收敛 journal 引用的快照**绝不自动清理**。判据是「读不到 ≠ 没有引用」——
|
|
@@ -734,7 +784,7 @@ export class FileSnapshotStore implements SnapshotStore {
|
|
|
734
784
|
referenced = await this.options.referencedSnapshotIds();
|
|
735
785
|
} catch (err) {
|
|
736
786
|
this.options.onWarn?.(engineMsg('core.backup.pruneAbandoned', { reason: err instanceof Error ? err.message : String(err) }));
|
|
737
|
-
return;
|
|
787
|
+
return emptyPlan;
|
|
738
788
|
}
|
|
739
789
|
}
|
|
740
790
|
// m-retention:策略读取失败 → 回退缺省(绝不因策略提供者异常而误删/崩溃)
|
|
@@ -762,17 +812,108 @@ export class FileSnapshotStore implements SnapshotStore {
|
|
|
762
812
|
// 损坏 / 非快照目录:跳过(与 listSnapshots 语义一致)
|
|
763
813
|
}
|
|
764
814
|
}
|
|
765
|
-
|
|
815
|
+
// --- BK-05:先出计划,再删 ---
|
|
816
|
+
// 快照本体与反悔副本是两套生命周期:本体按保留策略回收,pre-restore 按「份数 + 天数」淘汰。
|
|
817
|
+
// 计划必须在 rm **之前**成型并回调出去——删完再报等于审计拿不到原始状态。
|
|
818
|
+
const plan: PrunePlan = {
|
|
819
|
+
removeSnapshots: [],
|
|
820
|
+
keepPreRestoreFor: [],
|
|
821
|
+
dropPreRestore: await this.collectPreRestorePlan(),
|
|
822
|
+
paths: [],
|
|
823
|
+
};
|
|
824
|
+
/** 每个待回收快照:目录名 → 要删的子项(保持 pre-restore 不动) */
|
|
825
|
+
const removals: { id: string; children: string[]; hasPre: boolean }[] = [];
|
|
766
826
|
for (const id of selector(metas, policy)) {
|
|
767
827
|
const target = path.join(dir, id);
|
|
768
828
|
if (!target.startsWith(dir)) continue; // 越界 id 跳过(不删、不抛,同 save/readBlob 包含性约定)
|
|
829
|
+
const children = await fs.readdir(target).catch(() => [] as string[]);
|
|
830
|
+
const hasPre = children.includes(PRE_RESTORE_DIR_NAME);
|
|
831
|
+
removals.push({ id, children, hasPre });
|
|
832
|
+
plan.removeSnapshots.push(id);
|
|
833
|
+
for (const child of children) {
|
|
834
|
+
if (child === PRE_RESTORE_DIR_NAME) continue;
|
|
835
|
+
plan.paths.push(id + '/' + child);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
for (const drop of plan.dropPreRestore) plan.paths.push(drop.snapshotId + '/pre-restore/' + drop.attempt + '/');
|
|
839
|
+
this.options.onPrunePlan?.(plan);
|
|
840
|
+
if (plan.removeSnapshots.length === 0 && plan.dropPreRestore.length === 0) return plan;
|
|
841
|
+
|
|
842
|
+
// 1) 回收快照本体:有反悔副本的快照**不整目录删**(否则唯一那份原文跟着消失),逐项删其余内容
|
|
843
|
+
const removedIds: string[] = [];
|
|
844
|
+
for (const removal of removals) {
|
|
845
|
+
const target = path.join(dir, removal.id);
|
|
846
|
+
for (const child of removal.children) {
|
|
847
|
+
if (child === PRE_RESTORE_DIR_NAME) continue;
|
|
848
|
+
await fs.rm(path.join(target, child), { recursive: true, force: true });
|
|
849
|
+
}
|
|
850
|
+
if (removal.hasPre) plan.keepPreRestoreFor.push(removal.id);
|
|
851
|
+
else await fs.rm(target, { recursive: true, force: true });
|
|
852
|
+
removedIds.push(removal.id);
|
|
853
|
+
}
|
|
854
|
+
// 2) 反悔副本 attempt 级淘汰(份数超限 / 超过保留天数)
|
|
855
|
+
for (const drop of plan.dropPreRestore) {
|
|
856
|
+
await fs.rm(path.join(dir, drop.snapshotId, PRE_RESTORE_DIR_NAME, drop.attempt), { recursive: true, force: true });
|
|
857
|
+
}
|
|
858
|
+
// 3) 收尾:快照本体已回收(无 snapshot.json)且反悔副本也空了 ⇒ 不留空壳目录
|
|
859
|
+
const touched = new Set<string>([...plan.keepPreRestoreFor, ...plan.dropPreRestore.map((d) => d.snapshotId)]);
|
|
860
|
+
for (const id of touched) {
|
|
861
|
+
const target = path.join(dir, id);
|
|
862
|
+
const hasSnapshot = await fs.stat(path.join(target, 'snapshot.json')).catch(() => null) !== null;
|
|
863
|
+
if (hasSnapshot) continue;
|
|
864
|
+
const left = await fs.readdir(path.join(target, PRE_RESTORE_DIR_NAME)).catch(() => null);
|
|
865
|
+
if (left !== null && left.length > 0) continue;
|
|
769
866
|
await fs.rm(target, { recursive: true, force: true });
|
|
770
|
-
removedIds.push(id);
|
|
771
867
|
}
|
|
772
868
|
// Phase 6:自动保留清理历史回调(best-effort;不改变 prune 行为)
|
|
773
869
|
if (removedIds.length > 0) {
|
|
774
870
|
try { this.options.onPrune?.(removedIds); } catch { /* 历史回调失败不阻断 */ }
|
|
775
871
|
}
|
|
872
|
+
return plan;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* BK-05:反悔副本(pre-restore)淘汰计划 —— 只看**份数与天数**,与快照本体的保留策略无关。
|
|
877
|
+
*
|
|
878
|
+
* 为什么单独一套:快照本体是「历史回滚点」,可被 GFS 策略批量回收;反悔副本是用户把
|
|
879
|
+
* 刚才那次恢复撤回去的**唯一**原文(不在 blobs、不进同步、不进导出),它只需要「别无限长」,
|
|
880
|
+
* 不需要也不该受 keepLast/keepMonthly 影响 —— 否则一次批量淘汰会把最近几次恢复的反悔点一起抹掉。
|
|
881
|
+
* attempt 名即恢复时间戳(restore.ts 的 preRestoreStamp,本地时间 yyyyMMdd-HHmmss-SSS[-序号]);
|
|
882
|
+
* 解析不出来回落目录 mtime(手工改名的残留也能被淘汰,不会永久占位)。
|
|
883
|
+
*/
|
|
884
|
+
private async collectPreRestorePlan(): Promise<PrunePreRestoreDrop[]> {
|
|
885
|
+
const dir = this.options.dir;
|
|
886
|
+
const drops: PrunePreRestoreDrop[] = [];
|
|
887
|
+
const now = Date.now();
|
|
888
|
+
const maxAgeMs = PRE_RESTORE_KEEP_DAYS * 24 * 60 * 60 * 1000;
|
|
889
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
890
|
+
if (!entry.isDirectory()) continue;
|
|
891
|
+
const preRoot = path.join(dir, entry.name, PRE_RESTORE_DIR_NAME);
|
|
892
|
+
const attempts = await fs.readdir(preRoot, { withFileTypes: true }).catch(() => null);
|
|
893
|
+
if (attempts === null) continue;
|
|
894
|
+
const rows: { attempt: string; at: number; files: number }[] = [];
|
|
895
|
+
for (const a of attempts) {
|
|
896
|
+
if (!a.isDirectory()) continue;
|
|
897
|
+
const at = parsePreRestoreStamp(a.name)
|
|
898
|
+
?? (await fs.stat(path.join(preRoot, a.name)).then((s) => s.mtimeMs).catch(() => now));
|
|
899
|
+
const names = await fs.readdir(path.join(preRoot, a.name), { recursive: true }).catch(() => [] as string[]);
|
|
900
|
+
rows.push({ attempt: a.name, at, files: names.length });
|
|
901
|
+
}
|
|
902
|
+
rows.sort((x, y) => y.at - x.at); // 新 → 旧
|
|
903
|
+
rows.forEach((row, index) => {
|
|
904
|
+
if (index >= PRE_RESTORE_KEEP_ATTEMPTS) {
|
|
905
|
+
drops.push({ snapshotId: entry.name, attempt: row.attempt, reason: 'count', files: row.files });
|
|
906
|
+
} else if (now - row.at > maxAgeMs) {
|
|
907
|
+
drops.push({ snapshotId: entry.name, attempt: row.attempt, reason: 'over-age', files: row.files });
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return drops;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** BK-05:手动触发一轮保留清理(与 save 后的自动清理同一实现)。 */
|
|
915
|
+
async pruneNow(): Promise<PrunePlan> {
|
|
916
|
+
return this.prune();
|
|
776
917
|
}
|
|
777
918
|
|
|
778
919
|
async load(id: string): Promise<Snapshot> {
|
|
@@ -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 const DEFAULT_ROLLBACK_ON_ERROR = true;
|
package/src/core/importer.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
import type { ZipArchive, ZipSafetyLimits } from '../utils/zip.ts';
|
|
12
12
|
import type { MsgFunc } from './messages.ts';
|
|
13
13
|
import { Analyzer } from './analyzer.ts';
|
|
14
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from './import-defaults.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 缺省值的规范定义在 `./import-defaults.ts`(零依赖,UI 向导与浏览器 bundle 也引入它);
|
|
18
|
+
* 这里再导出一份,宿主侧调用方(路由、同步通道、CLI)可以从引擎门面拿,不必记两个路径。
|
|
19
|
+
*/
|
|
20
|
+
export { DEFAULT_ROLLBACK_ON_ERROR };
|
|
14
21
|
import type { PlanItemProgress } from './analyzer.ts';
|
|
15
22
|
import type { BootSafetyReport } from './boot-safety.ts';
|
|
16
23
|
import type {
|
|
@@ -41,7 +48,10 @@ export interface ExecuteOptions {
|
|
|
41
48
|
secretInputs?: Record<string, string>;
|
|
42
49
|
/** 加密备份解密结果(仅内存;解密必须经 m4 encryption provider) */
|
|
43
50
|
decryptedCredentials?: Map<string, string>;
|
|
44
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* 任一项失败立即整体回滚。缺省 = `DEFAULT_ROLLBACK_ON_ERROR`(`./import-defaults.ts`,全仓唯一一份);
|
|
53
|
+
* 要「单项失败如实记录并继续其余项」必须显式传 `false`。
|
|
54
|
+
*/
|
|
45
55
|
rollbackOnError?: boolean;
|
|
46
56
|
/** m1:每完成一个计划项的进度回调(透传给 Analyzer;不传则无埋点) */
|
|
47
57
|
onItem?: (info: PlanItemProgress) => void;
|
|
@@ -97,7 +107,7 @@ export class Importer {
|
|
|
97
107
|
confirm: opts.confirm,
|
|
98
108
|
secretInputs: opts.secretInputs,
|
|
99
109
|
decryptedCredentials: opts.decryptedCredentials,
|
|
100
|
-
rollbackOnError: opts.rollbackOnError,
|
|
110
|
+
rollbackOnError: opts.rollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR,
|
|
101
111
|
onItem: opts.onItem,
|
|
102
112
|
onItemStart: opts.onItemStart,
|
|
103
113
|
onLog: opts.onLog,
|