dsh-config-manager 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1263 -1192
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +10 -0
  36. package/lib/sync/git/git-transport.js +72 -14
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/package.json +2 -2
  56. package/src/adapters/self.test.ts +126 -85
  57. package/src/adapters/self.ts +73 -6
  58. package/src/client/ConfigManagerSection.tsx +0 -3
  59. package/src/client/common/Icon.tsx +3 -0
  60. package/src/client/common/SectionComposition.tsx +6 -1
  61. package/src/client/common/info-hint-guard.test.ts +7 -5
  62. package/src/client/config-manager.module.css +254 -13
  63. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  64. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  65. package/src/client/export/ExportView.tsx +3 -1
  66. package/src/client/home/HomePanel.tsx +25 -5
  67. package/src/client/import/ImportWizardView.tsx +1 -3
  68. package/src/client/import/import-wizard-steps.tsx +1 -17
  69. package/src/client/library/ArtifactRow.tsx +13 -2
  70. package/src/client/library/LibraryPanel.tsx +12 -2
  71. package/src/client/locales.ts +53 -34
  72. package/src/client/lucide-icons.d.ts +2 -0
  73. package/src/client/market/MarketPanel.tsx +12 -11
  74. package/src/client/market/market-locales.ts +16 -16
  75. package/src/client/recovery/RollbackCard.tsx +4 -2
  76. package/src/client/recovery/recovery-locales.test.ts +4 -2
  77. package/src/client/recovery/recovery-locales.ts +23 -23
  78. package/src/client/sync/AutosyncCard.tsx +36 -10
  79. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  80. package/src/client/sync/SyncPanel.tsx +32 -33
  81. package/src/client/sync/SyncProbeRow.tsx +23 -6
  82. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  83. package/src/client/sync/sync-locales.ts +24 -24
  84. package/src/core/analyzer.ts +37 -56
  85. package/src/core/backup.test.ts +139 -1
  86. package/src/core/backup.ts +145 -4
  87. package/src/core/import-defaults.ts +13 -0
  88. package/src/core/importer.ts +12 -2
  89. package/src/core/integrity-extra-entries.test.ts +205 -0
  90. package/src/core/messages.test.ts +3 -1
  91. package/src/core/messages.ts +8 -0
  92. package/src/core/model-tools.test.ts +33 -0
  93. package/src/core/model-tools.ts +20 -4
  94. package/src/index.ts +12 -21
  95. package/src/routes/import.ts +4 -1
  96. package/src/routes/recovery.test.ts +198 -1
  97. package/src/routes/recovery.ts +194 -1
  98. package/src/routes/sync.ts +27 -8
  99. package/src/security/integrity.ts +37 -0
  100. package/src/security/unverified-file-blocks.ts +192 -0
  101. package/src/sync/autosync-config.ts +9 -5
  102. package/src/sync/autosync-scheduler.test.ts +133 -6
  103. package/src/sync/autosync-scheduler.ts +118 -14
  104. package/src/sync/config-issue-reporting.test.ts +148 -0
  105. package/src/sync/config-issue-reporting.ts +75 -0
  106. package/src/sync/gc-skip.test.ts +66 -0
  107. package/src/sync/gc-skip.ts +64 -0
  108. package/src/sync/git/git-probe.test.ts +3 -2
  109. package/src/sync/git/git-transport.test.ts +168 -7
  110. package/src/sync/git/git-transport.ts +70 -14
  111. package/src/sync/merge-preview-text.ts +37 -0
  112. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  113. package/src/sync/s3/s3-transport.ts +23 -4
  114. package/src/sync/session-tombstones.ts +90 -0
  115. package/src/sync/sync-config.test.ts +170 -1
  116. package/src/sync/sync-config.ts +184 -14
  117. package/src/sync/sync-engine.test.ts +238 -0
  118. package/src/sync/sync-engine.ts +79 -83
  119. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  120. package/src/sync/webdav/webdav-transport.ts +26 -5
  121. package/src/ui/disk-usage-view.test.ts +2 -1
  122. package/src/ui/i18n.test.ts +47 -0
  123. package/src/ui/i18n.ts +58 -38
  124. package/src/ui/import-wizard.test.ts +18 -1
  125. package/src/ui/import-wizard.ts +3 -2
  126. package/src/ui/overview-view.test.ts +9 -0
  127. package/src/ui/overview-view.ts +9 -2
  128. package/src/ui/report.test.ts +4 -3
  129. package/src/ui/report.ts +2 -1
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 { readSyncConfig, validateRepoUrl, validateWebDavUrl, isWebDavConfig, isS3Config, isGistConfig, channelOf, parseSyncChannel, SYNC_CHANNELS, GIST_PROVIDER, isS3CompatProvider, validateCloudChannelInput, cloudSecretRef } from './sync/sync-config.js';
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.2.4';
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
- * 解析同步请求体并补全缺失字段(委托给导出的 parseSyncBody,便于单测)。
2455
- * username 回退:webdav 请求体未带 username(如挂载时 snapshotsList 自动加载、
2456
- * 表单留空后直接同步)时,从持久化 sync-config 回填已保存的 username——
2457
- * 否则 WebDavTransport 构造会因空 username 抛错,导致「保存过配置仍无法列出快照」。
2458
- * 语义与 password 一致:留空 = 沿用已保存凭据。
2459
- */
2460
- const prepareSync = async (body) => {
2461
- const cfg = await parseSyncBody(body, { credentials });
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 = {};
@@ -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
- rollbackOnError: opts['rollbackOnError'] === true,
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)再执行。
@@ -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 后走引擎。
@@ -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
- const full = await readFullSyncConfig(syncDir);
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 full = await readFullSyncConfig(syncDir);
261
+ const configIssues = [];
262
+ const full = await readFullSyncConfig(syncDir, configIssues);
255
263
  const repoUrl = full?.git?.repoUrl ?? '';
256
264
  if (repoUrl === '') {
257
- writeJson(res, 400, { error: msg('sync.workdirNotConfigured') });
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
- const cloud = await cloudChannelViews(await readFullSyncConfig(syncDir), credentials);
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
- writeJson(res, 200, { ok: true, snapshotId });
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>;