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/index.js
CHANGED
|
@@ -106,8 +106,9 @@ import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './sync/
|
|
|
106
106
|
import { selectPruneCandidates } from './core/backup.js';
|
|
107
107
|
import { appendAutosyncEntry } from './sync/sync-history.js';
|
|
108
108
|
import { MigrationStore, MIGRATION_HISTORY_DIR } from './core/migration-history.js';
|
|
109
|
-
import {
|
|
109
|
+
import { validateRepoUrl, validateWebDavUrl, isWebDavConfig, isS3Config, isGistConfig, channelOf, parseSyncChannel, SYNC_CHANNELS, GIST_PROVIDER, isS3CompatProvider, validateCloudChannelInput, cloudSecretRef } from './sync/sync-config.js';
|
|
110
110
|
import { resolveSessionFormatDisposition, sessionFormatAbortResponse, } from './routes/session-format.js';
|
|
111
|
+
import { createPrepareSync } from './sync/config-issue-reporting.js';
|
|
111
112
|
import { AUTO_SYNC_OPT_IN_SECTIONS, defaultSyncSelection, effectiveSections, OPT_IN_SYNC_SECTIONS, readAllSyncSelections, readSyncSelection } from './sync/sync-selection.js';
|
|
112
113
|
import { GitMarketReader } from './market/reader.js';
|
|
113
114
|
import { parseMarketIndex } from './market/index-parser.js';
|
|
@@ -136,7 +137,7 @@ export const name = 'config-manager';
|
|
|
136
137
|
/** Services required before the engine can mount (present in every profile). */
|
|
137
138
|
export const inject = ['settings', 'credentials'];
|
|
138
139
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
139
|
-
export const PLUGIN_VERSION = '0.
|
|
140
|
+
export const PLUGIN_VERSION = '0.3.0';
|
|
140
141
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
141
142
|
const PLUGIN_NAME = 'dsh-config-manager';
|
|
142
143
|
/**
|
|
@@ -2450,26 +2451,15 @@ function makeRoutes(deps) {
|
|
|
2450
2451
|
// 请求级装配:每次 push/pull 从请求体取 repoUrl,token 非空先写入 DSH
|
|
2451
2452
|
// credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
|
|
2452
2453
|
// 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
if (isWebDavConfig(cfg) && (cfg.webdav.username === undefined || cfg.webdav.username === '')) {
|
|
2463
|
-
try {
|
|
2464
|
-
const persisted = await readSyncConfig(syncDir);
|
|
2465
|
-
return mergePersistedWebDavUsername(cfg, persisted);
|
|
2466
|
-
}
|
|
2467
|
-
catch {
|
|
2468
|
-
return cfg; // 读失败保持原值(空 username 由 WebDavTransport 构造校验兜底报错)
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
return cfg;
|
|
2472
|
-
};
|
|
2454
|
+
// 同步请求体准备 = 「webdav username 回填 + SEC-03 配置被拒的一次性告警」。
|
|
2455
|
+
// 解析/去重/文案与读侧语义都在 src/sync/config-issue-reporting.ts(含单测),
|
|
2456
|
+
// 这里只做装配:宿主只提供 credentials、日志出口与配置目录。
|
|
2457
|
+
const prepareSync = createPrepareSync({
|
|
2458
|
+
syncDir,
|
|
2459
|
+
parseBody: (body) => parseSyncBody(body, { credentials }),
|
|
2460
|
+
mergeWebDavUsername: mergePersistedWebDavUsername,
|
|
2461
|
+
log: (line) => host.log.warn(line),
|
|
2462
|
+
});
|
|
2473
2463
|
/** 同步分区选择缓存(按通道;sync-selection.json;makeSyncEngine 同步读取用,保存路由更新)。
|
|
2474
2464
|
* 缺失通道 = 尚未加载(启动竞态窗口);读取/使用处兜底 defaultSyncSelection。 */
|
|
2475
2465
|
const selectionCache = {};
|
package/lib/routes/import.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* path/methods 就是唯一声明处;围栏、方法判定与顶层异常处理由 src/routes/kit.ts 在注册点统一提供。
|
|
6
6
|
*/
|
|
7
7
|
import { endpoint, requireJsonObject, readJsonBody, writeJson, queryParam } from './kit.js';
|
|
8
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.js';
|
|
8
9
|
import { decryptErrorText, isControlledPath, tryDecryptCredentials, writeRequestBodyToFile } from './route-support.js';
|
|
9
10
|
import { resolveSessionFormatDisposition, sessionFormatAbortResponse } from './session-format.js';
|
|
10
11
|
import { ENCRYPTED_CONTAINER_CODE, SecurityError, decryptArchive, isArchiveBlob, readContainerKind, verifyEncryptedBlob } from '../security/index.js';
|
|
@@ -237,7 +238,8 @@ export function importRoutes(env) {
|
|
|
237
238
|
secretInputs: opts['secretInputs'] !== null && typeof opts['secretInputs'] === 'object'
|
|
238
239
|
? opts['secretInputs']
|
|
239
240
|
: {},
|
|
240
|
-
|
|
241
|
+
// IE-02:调用方没写就落全仓同一缺省,别在这里另立一份 true/false
|
|
242
|
+
rollbackOnError: typeof opts['rollbackOnError'] === 'boolean' ? opts['rollbackOnError'] : DEFAULT_ROLLBACK_ON_ERROR,
|
|
241
243
|
decryptedCredentials,
|
|
242
244
|
// Phase 4 生产 snapshot 接线:deferred journal 绑定的 ctx 透传给引擎,
|
|
243
245
|
// 使快照创建后立即 bindSnapshot(SNAPSHOT_CREATED)→ markApplying(APPLYING)再执行。
|
package/lib/routes/recovery.js
CHANGED
|
@@ -17,7 +17,11 @@ import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/e
|
|
|
17
17
|
import { scanSessionHealth } from '../utils/session-health-scan.js';
|
|
18
18
|
import { applySessionTitles } from '../core/session-meta.js';
|
|
19
19
|
import { applySessionLogTitles } from '../core/session-title.js';
|
|
20
|
-
import { join } from 'node:path';
|
|
20
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
21
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
22
|
+
import { isPathSafe, isSameOrChild } from '../utils/paths.js';
|
|
23
|
+
import { writeZip } from '../utils/zip.js';
|
|
24
|
+
import { isValidSnapshotId } from '../core/restore.js';
|
|
21
25
|
import { applySessionRepair, listSessionRepairs, previewSessionRepair, rollbackSessionRepair, } from '../utils/session-repair-service.js';
|
|
22
26
|
import { applySessionLayoutRepair, planSessionLayoutRepair } from '../utils/session-layout-repair-service.js';
|
|
23
27
|
import { sessionHealthNextSteps } from './route-support.js';
|
|
@@ -306,8 +310,185 @@ function incompleteCopiesOf(profiles) {
|
|
|
306
310
|
return { copies: [], unreadable: true };
|
|
307
311
|
}
|
|
308
312
|
}
|
|
313
|
+
/* ------------------------------------------------------------------ BK-01
|
|
314
|
+
* 反悔副本(pre-restore)只读出口。
|
|
315
|
+
*
|
|
316
|
+
* 恢复链路在覆盖/删除任何文件前,会把当前原文复制到
|
|
317
|
+
* `<snapshotsDir>/<snapshotId>/pre-restore/<attempt>/`(src/core/restore.ts 的
|
|
318
|
+
* createPreRestoreAttemptDir / copyToPreRestore,文件名 `NNNN-<label>`)。
|
|
319
|
+
* BK-01:这份数据此前在 host 侧零出口 —— 用户既看不见、也取不出来,等于「存在但不可达」。
|
|
320
|
+
* 这里只做两件**零写入**的事:列清单(GET)与把某个 attempt 打成 zip 落到用户指定的导出目录(POST)。
|
|
321
|
+
*
|
|
322
|
+
* 硬边界:
|
|
323
|
+
* - 整条链路不写快照目录一个字节(源目录里连临时文件都不许留);
|
|
324
|
+
* - zip 只落用户显式给出的**已存在**目录,目标同名文件已存在即 409(绝不就地覆盖任何既有文件);
|
|
325
|
+
* - 导出目录落在源目录之内(或源目录落在导出目录之内)一律 400。
|
|
326
|
+
*/
|
|
327
|
+
const PRE_RESTORE_DIR = 'pre-restore';
|
|
328
|
+
/** 单次枚举的上限:attempt 数 / 文件数 / 递归深度 / 单次导出总字节。防一个坏目录把首屏拖垮。 */
|
|
329
|
+
const PRE_RESTORE_MAX_ATTEMPTS = 200;
|
|
330
|
+
const PRE_RESTORE_MAX_FILES = 2000;
|
|
331
|
+
const PRE_RESTORE_MAX_DEPTH = 8;
|
|
332
|
+
const PRE_RESTORE_EXPORT_MAX_BYTES = 256 * 1024 * 1024;
|
|
333
|
+
/** attempt 目录名 = 恢复时间戳 `YYYYMMDD-HHMMSS-mmm`(restore.ts 的 preRestoreStamp),可选 `-N` 去重后缀。 */
|
|
334
|
+
const PRE_RESTORE_ATTEMPT_RE = /^[0-9]{8}-[0-9]{6}-[0-9]{3}(-[0-9]+)?$/;
|
|
335
|
+
function isPreRestoreAttemptName(name) {
|
|
336
|
+
return typeof name === 'string' && PRE_RESTORE_ATTEMPT_RE.test(name);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* 递归走一遍 attempt 目录,只**读元数据**(readdir + stat),不读文件内容。
|
|
340
|
+
* symlink 不跟随(只计数):反悔副本里出现链接本身就可疑,跟过去会把 home 外的东西带进 zip。
|
|
341
|
+
* 超深度 / 超文件数 / 超字节即 `truncated` —— 调用方必须把截断当成「没说全」,不能当成「就这么多」。
|
|
342
|
+
*/
|
|
343
|
+
async function walkPreRestore(dir, maxBytes) {
|
|
344
|
+
const files = [];
|
|
345
|
+
let totalBytes = 0;
|
|
346
|
+
let skippedLinks = 0;
|
|
347
|
+
let truncated = false;
|
|
348
|
+
const walk = async (current, rel, depth) => {
|
|
349
|
+
if (truncated)
|
|
350
|
+
return;
|
|
351
|
+
if (depth > PRE_RESTORE_MAX_DEPTH) {
|
|
352
|
+
truncated = true;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => null);
|
|
356
|
+
if (entries === null) {
|
|
357
|
+
truncated = true;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
if (truncated)
|
|
363
|
+
return;
|
|
364
|
+
const abs = join(current, entry.name);
|
|
365
|
+
const relativePath = rel === '' ? entry.name : rel + '/' + entry.name;
|
|
366
|
+
if (entry.isSymbolicLink()) {
|
|
367
|
+
skippedLinks += 1;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (entry.isDirectory()) {
|
|
371
|
+
await walk(abs, relativePath, depth + 1);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (!entry.isFile()) {
|
|
375
|
+
skippedLinks += 1;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const info = await stat(abs).catch(() => null);
|
|
379
|
+
if (info === null || !info.isFile()) {
|
|
380
|
+
skippedLinks += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (files.length >= PRE_RESTORE_MAX_FILES) {
|
|
384
|
+
truncated = true;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (maxBytes !== undefined && totalBytes + info.size > maxBytes) {
|
|
388
|
+
truncated = true;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
files.push({ relativePath, sizeBytes: info.size, mtimeMs: info.mtimeMs });
|
|
392
|
+
totalBytes += info.size;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
await walk(dir, '', 0);
|
|
396
|
+
return { files, totalBytes, skippedLinks, truncated };
|
|
397
|
+
}
|
|
398
|
+
/** 列出快照根下每个快照的 pre-restore attempt 与文件清单。只读;快照根读不动如实外露。 */
|
|
399
|
+
async function listPreRestore(snapshotsDir) {
|
|
400
|
+
const roots = await readdir(snapshotsDir, { withFileTypes: true }).catch(() => null);
|
|
401
|
+
if (roots === null) {
|
|
402
|
+
// 与 listSnapshots 同口径:读不动就如实说「读不动」,不抛 500、也不假装「没有反悔副本」
|
|
403
|
+
return { readOnly: true, snapshots: [], unreadable: snapshotsDir };
|
|
404
|
+
}
|
|
405
|
+
const snapshots = [];
|
|
406
|
+
const ids = roots.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
407
|
+
for (const snapshotId of ids) {
|
|
408
|
+
if (!isValidSnapshotId(snapshotId))
|
|
409
|
+
continue;
|
|
410
|
+
const base = join(snapshotsDir, snapshotId, PRE_RESTORE_DIR);
|
|
411
|
+
const entries = await readdir(base, { withFileTypes: true }).catch((error) => error);
|
|
412
|
+
if (!Array.isArray(entries)) {
|
|
413
|
+
const code = entries.code;
|
|
414
|
+
// ENOENT = 这个快照从未做过恢复(正常态,不列);其余读不动如实标注
|
|
415
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
416
|
+
continue;
|
|
417
|
+
snapshots.push({ snapshotId, attempts: [], unreadable: entries instanceof Error ? entries.message : String(entries) });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const attempts = entries.filter((e) => e.isDirectory() && isPreRestoreAttemptName(e.name)).map((e) => e.name);
|
|
421
|
+
if (attempts.length === 0)
|
|
422
|
+
continue;
|
|
423
|
+
attempts.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); // 新 → 旧
|
|
424
|
+
const kept = attempts.slice(0, PRE_RESTORE_MAX_ATTEMPTS);
|
|
425
|
+
const list = [];
|
|
426
|
+
for (const attempt of kept) {
|
|
427
|
+
const walked = await walkPreRestore(join(base, attempt));
|
|
428
|
+
list.push({ attempt, ...walked });
|
|
429
|
+
}
|
|
430
|
+
snapshots.push({ snapshotId, attempts: list, ...(attempts.length > kept.length ? { truncated: true } : {}) });
|
|
431
|
+
}
|
|
432
|
+
return { readOnly: true, snapshots, unreadable: null };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* 把某个 attempt 目录打成 zip 落到用户指定目录。
|
|
436
|
+
* 码表(界面按 code 出文案,不靠状态码猜原因):
|
|
437
|
+
* invalid-input 400 / attempt-not-found 404 / export-dir-missing 400 /
|
|
438
|
+
* export-inside-source 400 / export-target-exists 409 / export-too-large 413
|
|
439
|
+
*/
|
|
440
|
+
async function exportPreRestoreZip(snapshotsDir, body) {
|
|
441
|
+
const fail = (status, code, detail) => ({ ok: false, status, code, ...(detail !== undefined ? { detail } : {}) });
|
|
442
|
+
const { snapshotId, attempt, outputDir } = body;
|
|
443
|
+
if (!isValidSnapshotId(snapshotId))
|
|
444
|
+
return fail(400, 'invalid-input', 'snapshotId');
|
|
445
|
+
if (!isPreRestoreAttemptName(attempt))
|
|
446
|
+
return fail(400, 'invalid-input', 'attempt');
|
|
447
|
+
// 导出目录必须是**绝对路径**且不带上跳段:相对路径会按宿主进程 cwd 解析,用户根本不知道写到哪去了
|
|
448
|
+
if (typeof outputDir !== 'string' || outputDir.trim() === '' || !isAbsolute(outputDir) || outputDir.split(/[\\/]+/).includes('..')) {
|
|
449
|
+
return fail(400, 'invalid-input', 'outputDir');
|
|
450
|
+
}
|
|
451
|
+
const outDir = resolve(outputDir);
|
|
452
|
+
const attemptDir = resolve(join(snapshotsDir, snapshotId, PRE_RESTORE_DIR, attempt));
|
|
453
|
+
// 双保险:id / attempt 已过白名单,再按解析后的路径确认没跳出快照根
|
|
454
|
+
if (!isSameOrChild(attemptDir, resolve(snapshotsDir)))
|
|
455
|
+
return fail(400, 'invalid-input', 'attempt');
|
|
456
|
+
const srcInfo = await stat(attemptDir).catch(() => null);
|
|
457
|
+
if (srcInfo === null || !srcInfo.isDirectory())
|
|
458
|
+
return fail(404, 'attempt-not-found');
|
|
459
|
+
// 导出目录必须已存在:不替用户造目录(造出来的目录没人认,还可能落在错的盘上)
|
|
460
|
+
const outInfo = await stat(outDir).catch(() => null);
|
|
461
|
+
if (outInfo === null || !outInfo.isDirectory())
|
|
462
|
+
return fail(400, 'export-dir-missing');
|
|
463
|
+
// 双向 containment 都是「往源里写」:导出目录在 attempt 内 / attempt 在导出目录内(含快照根本身)
|
|
464
|
+
if (isSameOrChild(outDir, attemptDir) || isSameOrChild(attemptDir, outDir))
|
|
465
|
+
return fail(400, 'export-inside-source');
|
|
466
|
+
const zipPath = join(outDir, `pre-restore-${snapshotId}-${attempt}.zip`);
|
|
467
|
+
const walked = await walkPreRestore(attemptDir, PRE_RESTORE_EXPORT_MAX_BYTES);
|
|
468
|
+
if (walked.truncated)
|
|
469
|
+
return fail(413, 'export-too-large');
|
|
470
|
+
// 目标已存在 ⇒ 409:writeZip 内部会 rename 覆盖,绝不能让「导出」顺手抹掉用户已有的文件
|
|
471
|
+
if ((await stat(zipPath).catch(() => null)) !== null)
|
|
472
|
+
return fail(409, 'export-target-exists');
|
|
473
|
+
const entries = [];
|
|
474
|
+
for (const file of walked.files) {
|
|
475
|
+
if (!isPathSafe(file.relativePath))
|
|
476
|
+
return fail(400, 'invalid-input', 'entry');
|
|
477
|
+
const data = await readFile(join(attemptDir, file.relativePath)).catch(() => null);
|
|
478
|
+
if (data === null)
|
|
479
|
+
return fail(500, 'read-failed', file.relativePath);
|
|
480
|
+
entries.push({ name: file.relativePath, data: new Uint8Array(data) });
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
await writeZip(zipPath, entries, { maxEntries: PRE_RESTORE_MAX_FILES });
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
return fail(500, 'zip-write-failed', error instanceof Error ? error.message : String(error));
|
|
487
|
+
}
|
|
488
|
+
return { ok: true, zipPath, entries: entries.length, totalBytes: walked.totalBytes, skippedLinks: walked.skippedLinks };
|
|
489
|
+
}
|
|
309
490
|
export function recoveryRoutes(env) {
|
|
310
|
-
const { dataDir, host, makeRecoveryExecutors, profiles, recoveryOrchestrator, sessionHealth, tryAppendHistory, } = env;
|
|
491
|
+
const { dataDir, host, makeRecoveryExecutors, profiles, recoveryOrchestrator, sessionHealth, snapshotsDir, tryAppendHistory, } = env;
|
|
311
492
|
return [
|
|
312
493
|
// ------------------------------------------------------------ recovery
|
|
313
494
|
// Phase 5:recovery 编排(prefix 路由,内部按 path 分发)。
|
|
@@ -526,6 +707,49 @@ export function recoveryRoutes(env) {
|
|
|
526
707
|
}
|
|
527
708
|
return;
|
|
528
709
|
}
|
|
710
|
+
// ------------------------------- BK-01:反悔副本(pre-restore)只读出口
|
|
711
|
+
// 挂在既有 recovery prefix 内,按 segments 分发 —— **不新增 endpoint 注册点**(本族本就是 prefix
|
|
712
|
+
// 路由,路由总数仍是 83;窗口守卫钉住那 7 条不受影响)。
|
|
713
|
+
// 零写入:GET 只读元数据;POST 只往用户显式指定的导出目录写一个 zip,快照目录一字节不动。
|
|
714
|
+
if (segments[0] === 'pre-restore') {
|
|
715
|
+
const sub = segments.slice(1);
|
|
716
|
+
if (sub.length === 0) {
|
|
717
|
+
if (req.method !== 'GET') {
|
|
718
|
+
writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
writeJson(res, 200, await listPreRestore(snapshotsDir));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (sub.length === 1 && sub[0] === 'export') {
|
|
725
|
+
if (req.method !== 'POST') {
|
|
726
|
+
writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const body = await readJsonBody(req);
|
|
730
|
+
if (body === undefined) {
|
|
731
|
+
writeJson(res, 400, { error: 'invalid-input', code: 'invalid-input' });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const result = await exportPreRestoreZip(snapshotsDir, body);
|
|
735
|
+
if (!result.ok) {
|
|
736
|
+
writeJson(res, result.status, { error: result.code, code: result.code, ...(result.detail !== undefined ? { detail: result.detail } : {}) });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// 导出是「用户拿到了一份文件」这件事实,照既有 recovery 子路径的口径落一条历史
|
|
740
|
+
await tryAppendHistory({
|
|
741
|
+
kind: 'recovery',
|
|
742
|
+
result: 'success',
|
|
743
|
+
sections: [],
|
|
744
|
+
source: 'recovery',
|
|
745
|
+
summary: engineMsg('route.recoveryOperationSummary', { action: 'export-pre-restore' }),
|
|
746
|
+
}).catch(() => undefined);
|
|
747
|
+
writeJson(res, 200, result);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
writeJson(res, 404, { error: 'not found' });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
529
753
|
// ---------------------------------------- S1–S8:工作区 + 会话回滚点
|
|
530
754
|
// 回滚点 = 工作区快照 + 会话游标(宿主按回合自动留点)。**没有手动「拍点」表单**,
|
|
531
755
|
// 因此这里没有 capture 子路径 —— 留点由宿主订阅 session/event 后走引擎。
|
package/lib/routes/sync.js
CHANGED
|
@@ -145,8 +145,12 @@ export function syncRoutes(env) {
|
|
|
145
145
|
*/
|
|
146
146
|
const collectSyncStatus = async (opts) => {
|
|
147
147
|
// 完整双命名空间配置:repoUrl / webdav.url 无论当前通道都回填,
|
|
148
|
-
// 保证 UI 在 git ↔ webdav
|
|
149
|
-
|
|
148
|
+
// 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失。
|
|
149
|
+
// SEC-03 读侧可见性:已落盘但过不了白名单的 repoUrl 会被 readFullSyncConfig 丢弃
|
|
150
|
+
// (绝不交给凭据注入/git 子进程),但**必须把成因带出去** —— 否则界面显示「未配置」,
|
|
151
|
+
// 用户永远看不出自己保存过的地址是被安全校验拒掉,而不是从来没存过。
|
|
152
|
+
const configIssues = [];
|
|
153
|
+
const full = await readFullSyncConfig(syncDir, configIssues);
|
|
150
154
|
const state = await loadSyncState(syncDir);
|
|
151
155
|
const [cred, webdavCred] = await Promise.all([
|
|
152
156
|
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
@@ -179,6 +183,9 @@ export function syncRoutes(env) {
|
|
|
179
183
|
configured: full !== null,
|
|
180
184
|
transport,
|
|
181
185
|
repoUrl: full?.git?.repoUrl,
|
|
186
|
+
// 被丢弃的通道配置成因(默认空数组:字段恒在,客户端不必判 undefined;
|
|
187
|
+
// 合法配置不产生幻影告警)。code 是机读 token,message 可直接展示。
|
|
188
|
+
configIssues,
|
|
182
189
|
credentialConfigured: cred.configured,
|
|
183
190
|
credentialWritable: cred.writable === true,
|
|
184
191
|
// webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
|
|
@@ -251,10 +258,17 @@ export function syncRoutes(env) {
|
|
|
251
258
|
writeJson(res, 400, { error: msg('sync.workdirRecloneNeedsConfirm') });
|
|
252
259
|
return;
|
|
253
260
|
}
|
|
254
|
-
const
|
|
261
|
+
const configIssues = [];
|
|
262
|
+
const full = await readFullSyncConfig(syncDir, configIssues);
|
|
255
263
|
const repoUrl = full?.git?.repoUrl ?? '';
|
|
256
264
|
if (repoUrl === '') {
|
|
257
|
-
|
|
265
|
+
// SEC-03:区分「地址被安全校验拒掉」与「从来就没配」。两者都 400、都不重建,
|
|
266
|
+
// 但文案必须说实话 —— 说成「尚未配置」会让用户反复重填同一个被拒地址。
|
|
267
|
+
const rejected = configIssues.find((issue) => issue.channel === 'git');
|
|
268
|
+
writeJson(res, 400, {
|
|
269
|
+
error: rejected === undefined ? msg('sync.workdirNotConfigured') : rejected.message,
|
|
270
|
+
configIssues,
|
|
271
|
+
});
|
|
258
272
|
return;
|
|
259
273
|
}
|
|
260
274
|
const rebuilt = await syncWorkDir.rebuild(repoUrl, mode);
|
|
@@ -326,11 +340,15 @@ export function syncRoutes(env) {
|
|
|
326
340
|
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
327
341
|
credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
|
|
328
342
|
]);
|
|
329
|
-
|
|
343
|
+
// SEC-03:写完就把落盘结果再读一遍(writeSyncConfig 可能因命名空间合并保留旧值),
|
|
344
|
+
// 成因而非「写成功」才是客户端要显示的真相;字段恒在(默认空数组)。
|
|
345
|
+
const configIssues = [];
|
|
346
|
+
const cloud = await cloudChannelViews(await readFullSyncConfig(syncDir, configIssues), credentials);
|
|
330
347
|
writeJson(res, 200, {
|
|
331
348
|
ok: true,
|
|
332
349
|
configured: true,
|
|
333
350
|
transport: syncCfg.transport,
|
|
351
|
+
configIssues,
|
|
334
352
|
credentialConfigured: cred.configured,
|
|
335
353
|
// 通道判定消费单一来源 channelOf(B7 守卫扩到覆盖该谓词形态;此前是裸 isWebDavConfig 分支)
|
|
336
354
|
webdav: channelOf(syncCfg) === 'webdav' ? describeWebDavSlot(syncCfg, webdavCred.configured) : undefined,
|
|
@@ -769,8 +787,9 @@ export function syncRoutes(env) {
|
|
|
769
787
|
}
|
|
770
788
|
const syncCfg = await prepareSync(body);
|
|
771
789
|
const engine = makeSyncEngine(syncCfg);
|
|
772
|
-
await withTimeout(engine.deleteSnapshot(snapshotId), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'));
|
|
773
|
-
|
|
790
|
+
const deleted = await withTimeout(engine.deleteSnapshot(snapshotId), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'));
|
|
791
|
+
// 远端孤儿 blob 回收被放弃时要能看见(RV1-F3):空数组 = 本轮什么都没被放弃。
|
|
792
|
+
writeJson(res, 200, { ok: true, snapshotId, warnings: deleted.warnings });
|
|
774
793
|
}
|
|
775
794
|
catch (error) {
|
|
776
795
|
writeSyncRouteError(res, error);
|
|
@@ -25,5 +25,29 @@ export declare function parseChecksumsTable(raw: string, opts?: ChecksumsTableOp
|
|
|
25
25
|
export declare function verifyChecksums(entries: ReadonlyMap<string, Uint8Array>, table: Record<string, string>): ChecksumVerifyResult;
|
|
26
26
|
/** 一步到位:解析 checksums.json 原文并校验(解析失败抛错;不符返回 {ok:false,...}) */
|
|
27
27
|
export declare function verifyChecksumsJson(entries: ReadonlyMap<string, Uint8Array>, raw: string, opts?: ChecksumsTableOptions): ChecksumVerifyResult;
|
|
28
|
+
/**
|
|
29
|
+
* 会落盘的文件分区条目「未登记进校验表」时的错误码(写进 `PlanItem.detail`,也进执行报告)。
|
|
30
|
+
*
|
|
31
|
+
* 语义不是「哈希不符」(那由 `import.integrityFailed` 表示,且直接失败),而是
|
|
32
|
+
* 「这个条目根本没被校验表覆盖」—— 完整性声明对它零约束(SEC-05)。
|
|
33
|
+
*/
|
|
34
|
+
export declare const UNVERIFIED_FILE_ENTRY_CODE = "unverified-file-entry";
|
|
35
|
+
/**
|
|
36
|
+
* 上述状态的文案键。字典条目由维护方补齐;缺失时 messages 回退成键名本身、绝不抛错,
|
|
37
|
+
* 所以导入流程不会因为缺一条文案而中断。
|
|
38
|
+
*/
|
|
39
|
+
export declare const UNVERIFIED_FILE_ENTRY_KEY = "import.unverifiedFileEntry";
|
|
40
|
+
/**
|
|
41
|
+
* 校验表的**反向覆盖**:entries 里存在、校验表里没登记的路径。
|
|
42
|
+
*
|
|
43
|
+
* `verifyChecksums` 只正向遍历表里的键(登记的必须存在且 SHA-256 一致),**表外条目它不看**;
|
|
44
|
+
* 而文件类分区是按归档名原样落盘的(`adapters/file-collection.ts` 的 applyItem),
|
|
45
|
+
* 于是「ZIP 里有、checksums 没登记」的文件照样写进目标目录 —— 完整性声明只约束了登记过的那一半。
|
|
46
|
+
*
|
|
47
|
+
* 本函数是「谁没被覆盖」的**单一事实源**:loadBundle 的告警与导入侧的阻断判定共用它,
|
|
48
|
+
* 不各算一遍(两份实现迟早分叉)。判据与既有告警逐字一致:
|
|
49
|
+
* 跳过目录条目(以 `/` 结尾)与调用方给出的结构文件(manifest.json / integrity/checksums.json)。
|
|
50
|
+
*/
|
|
51
|
+
export declare function unverifiedEntries(names: readonly string[], table: Record<string, string>, exclude?: readonly string[]): string[];
|
|
28
52
|
/** 人类可读的不匹配摘要(给「Backup integrity check failed」错误消息用) */
|
|
29
53
|
export declare function describeMismatches(result: ChecksumVerifyResult): string;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* 危险键(防 prototype pollution);解析失败抛错 → 导入方归为「Backup integrity check failed」。
|
|
11
11
|
* - 一步到位 `verifyChecksumsJson`:解析 + 校验合并,供 m5/导入管线直接调用。
|
|
12
12
|
* - 校验语义:表内每个路径必须在条目集合中存在且 SHA-256 一致;缺失与不符均计入 mismatches。
|
|
13
|
+
* - 反向覆盖(SEC-05):`unverifiedEntries` 算出「ZIP 有、表里没登记」的条目 —— 正向校验看不到
|
|
14
|
+
* 它们,而文件类分区按归档名原样落盘,缺了这条判定就等于「完整性只覆盖一半」。
|
|
13
15
|
*/
|
|
14
16
|
import { parseJsonSafe } from '../utils/json.js';
|
|
15
17
|
import { buildChecksums as coreBuildChecksums, verifyChecksums as coreVerifyChecksums, } from '../utils/hashing.js';
|
|
@@ -67,6 +69,34 @@ export function verifyChecksumsJson(entries, raw, opts = {}) {
|
|
|
67
69
|
const table = parseChecksumsTable(raw, opts);
|
|
68
70
|
return verifyChecksums(entries, table);
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* 会落盘的文件分区条目「未登记进校验表」时的错误码(写进 `PlanItem.detail`,也进执行报告)。
|
|
74
|
+
*
|
|
75
|
+
* 语义不是「哈希不符」(那由 `import.integrityFailed` 表示,且直接失败),而是
|
|
76
|
+
* 「这个条目根本没被校验表覆盖」—— 完整性声明对它零约束(SEC-05)。
|
|
77
|
+
*/
|
|
78
|
+
export const UNVERIFIED_FILE_ENTRY_CODE = 'unverified-file-entry';
|
|
79
|
+
/**
|
|
80
|
+
* 上述状态的文案键。字典条目由维护方补齐;缺失时 messages 回退成键名本身、绝不抛错,
|
|
81
|
+
* 所以导入流程不会因为缺一条文案而中断。
|
|
82
|
+
*/
|
|
83
|
+
export const UNVERIFIED_FILE_ENTRY_KEY = 'import.unverifiedFileEntry';
|
|
84
|
+
/**
|
|
85
|
+
* 校验表的**反向覆盖**:entries 里存在、校验表里没登记的路径。
|
|
86
|
+
*
|
|
87
|
+
* `verifyChecksums` 只正向遍历表里的键(登记的必须存在且 SHA-256 一致),**表外条目它不看**;
|
|
88
|
+
* 而文件类分区是按归档名原样落盘的(`adapters/file-collection.ts` 的 applyItem),
|
|
89
|
+
* 于是「ZIP 里有、checksums 没登记」的文件照样写进目标目录 —— 完整性声明只约束了登记过的那一半。
|
|
90
|
+
*
|
|
91
|
+
* 本函数是「谁没被覆盖」的**单一事实源**:loadBundle 的告警与导入侧的阻断判定共用它,
|
|
92
|
+
* 不各算一遍(两份实现迟早分叉)。判据与既有告警逐字一致:
|
|
93
|
+
* 跳过目录条目(以 `/` 结尾)与调用方给出的结构文件(manifest.json / integrity/checksums.json)。
|
|
94
|
+
*/
|
|
95
|
+
export function unverifiedEntries(names, table, exclude = []) {
|
|
96
|
+
const skip = new Set(exclude);
|
|
97
|
+
const covered = new Set(Object.keys(table));
|
|
98
|
+
return names.filter((name) => !name.endsWith('/') && !skip.has(name) && !covered.has(name));
|
|
99
|
+
}
|
|
70
100
|
/** 人类可读的不匹配摘要(给「Backup integrity check failed」错误消息用) */
|
|
71
101
|
export function describeMismatches(result) {
|
|
72
102
|
const parts = [
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ChecksumVerifyResult } from '../utils/hashing.ts';
|
|
2
|
+
import type { MsgFunc } from '../core/msg-types.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 本模块需要的计划项字段(结构子集)。刻意不 import `core/types.ts` 的 `PlanItem`:
|
|
5
|
+
* 值依赖方向是 core → security,security 只描述契约。
|
|
6
|
+
*/
|
|
7
|
+
export interface UnverifiedBlockableItem {
|
|
8
|
+
adapter: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
severity?: string;
|
|
11
|
+
detail?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
target?: {
|
|
14
|
+
ref?: string;
|
|
15
|
+
} | undefined;
|
|
16
|
+
}
|
|
17
|
+
export declare function unverifiedFileEntryText(text: MsgFunc, names: readonly string[]): string;
|
|
18
|
+
/**
|
|
19
|
+
* 未覆盖条目的**唯一**一条告警(RV1-F11:同一事实只发一次,不再与 `import.extraEntries` 并存)。
|
|
20
|
+
*
|
|
21
|
+
* 键按「到底拦没拦」选,避免文案自称做了没做的事:
|
|
22
|
+
* - 真拦下了(blocked > 0)⇒ 专属键 `import.unverifiedFileEntry`(「已逐条阻断写入」);
|
|
23
|
+
* - 一个都没拦(豁免分区 / 不在任何文件分区前缀下的散落条目,守 INT-01)⇒ 中性口径 `import.extraEntries`。
|
|
24
|
+
*/
|
|
25
|
+
export declare function unverifiedNoticeText(text: MsgFunc, names: readonly string[], blocked: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* 校验表**覆盖不到**的条目(反向覆盖)。
|
|
28
|
+
*
|
|
29
|
+
* - 表存在且非空:表里没登记的条目(`unverifiedEntries` 的判据,正向校验看不到它们);
|
|
30
|
+
* - 表**缺失/为空**:一个条目都没被校验 ⇒ 文件分区前缀下的条目一律视为未覆盖。
|
|
31
|
+
* 这是 RV1-F1 抓到的绕过:删掉 `integrity/checksums.json`(或置 `{}`)就等于把 SEC-05 关掉。
|
|
32
|
+
* 口径选「逐条 fail-closed」而非整包拒:旧包/手工包的非文件分区(settings 等)照常导入,
|
|
33
|
+
* 被拦下的只是「按归档名原样落盘、完整性声明又完全管不到」的那部分。
|
|
34
|
+
*
|
|
35
|
+
* 文案不在这里出(RV1-F11):同一事实只由 `analyzeImport` 发一条 `import.unverifiedFileEntry`。
|
|
36
|
+
*/
|
|
37
|
+
export declare function scanUnverifiedEntries(names: readonly string[], table: Record<string, string> | null): string[];
|
|
38
|
+
/**
|
|
39
|
+
* 把命中「表外 + 会写盘」的文件分区计划项升级为 Error。
|
|
40
|
+
*
|
|
41
|
+
* 为什么必须拦:文件集合分区按归档名原样落盘(`adapters/file-collection.ts` 的 applyItem 只看
|
|
42
|
+
* `target.ref`),而 `verifyAgainstTable` 只正向校验表里登记的条目 —— 表外文件一路绿灯。
|
|
43
|
+
* 拦在执行之前、拦在计划项上(kind:'Error'):`applyOne` 遇到 Error 直接记 failed、绝不写目标,
|
|
44
|
+
* 用户也能在预览里看到「为什么这条没导」。
|
|
45
|
+
*
|
|
46
|
+
* @returns 被阻断的归档条目名(按计划项顺序,供调用方汇总一条提示)
|
|
47
|
+
*/
|
|
48
|
+
export declare function applyUnverifiedFileBlocks(unverified: readonly string[], items: UnverifiedBlockableItem[], fileSections: ReadonlySet<string>, exemptSections: readonly string[], text: MsgFunc): string[];
|
|
49
|
+
/** ZIP 归档读取子集(结构子集:`ZipArchive` 可赋值);本模块不 import core 的 ZIP 解析。 */
|
|
50
|
+
export interface IntegrityArchive {
|
|
51
|
+
has(name: string): boolean;
|
|
52
|
+
names(): string[];
|
|
53
|
+
readEntryText(name: string): string;
|
|
54
|
+
readEntryAsync(name: string): Promise<Uint8Array>;
|
|
55
|
+
}
|
|
56
|
+
export interface ArchiveIntegrityScan {
|
|
57
|
+
checksums: ChecksumVerifyResult;
|
|
58
|
+
/** 表外条目(反向覆盖);校验表缺失/为空时 = 文件分区前缀下的全部条目(见 scanUnverifiedEntries) */
|
|
59
|
+
unverified: string[];
|
|
60
|
+
/** 要挂进 zipWarnings 的文案(目前只有「校验表缺失」;反向覆盖文案由 analyzeImport 统一发一条) */
|
|
61
|
+
warnings: string[];
|
|
62
|
+
/** 完整性不符时的错误文案;undefined = 通过(调用方自己 throw,文案由注入的 msg 决定) */
|
|
63
|
+
failure?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 归档完整性扫描:正向(表内每条必须存在且 SHA-256 一致)+ 反向(ZIP 有、表里没登记)。
|
|
67
|
+
*
|
|
68
|
+
* 正向用 `utils/hashing.ts` 的 `verifyChecksums` —— 与 `import-planning.ts` 里的同名判据逐字等价,
|
|
69
|
+
* 是同一份语义的规范实现,此处不再复制第二遍。
|
|
70
|
+
* 判定却必须与导入阻断共用(`scanUnverifiedEntries` / `unverifiedEntries`),否则「告警说的表外条目」
|
|
71
|
+
* 与「真正被拦下的条目」会分叉。
|
|
72
|
+
*/
|
|
73
|
+
export declare function scanArchiveIntegrity(archive: IntegrityArchive, text: MsgFunc): Promise<ArchiveIntegrityScan>;
|