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
@@ -30,7 +30,7 @@ export const zh = {
30
30
  'channel.gist': 'GitHub Gist',
31
31
  'channel.perChannelHint': '每条通道(Git / WebDAV / 对象存储 / Gist)的自动同步、同步模式、加密与远端快照各自独立配置。',
32
32
  // 通道入口卡(弹窗驱动:点按钮 → 弹窗内配置通道)
33
- 'channel.open': '配置…',
33
+ 'channel.open': '配置',
34
34
  'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV 服务器。',
35
35
  // 断开通道配置(用户实测要求:配置过就必须能删掉,否则打不通的通道会永久占位)
36
36
  'channel.expand': '展开通道详情',
@@ -106,7 +106,7 @@ export const zh = {
106
106
  'repoPicker.empty': '没有可用的私有仓库',
107
107
  'repoPicker.loadFailed': '仓库列表加载失败,可重试或直接手填地址。',
108
108
  'repoPicker.updatedAt': '更新于 {time}',
109
- 'repoPicker.create': '+ 新建私有仓库…',
109
+ 'repoPicker.create': '+ 新建私有仓库',
110
110
  'repoPicker.createTitle': '新建私有仓库',
111
111
  'repoPicker.name': '仓库名',
112
112
  'repoPicker.namePlaceholder': '如 dsh-configs',
@@ -180,7 +180,7 @@ export const zh = {
180
180
  // 同步模式(默认快速导出 / 高级自定义导出)
181
181
  'mode.title': '同步分区',
182
182
  'mode.hint': '同步范围 = 勾选的分区集合(默认已勾选推荐分区,可随时改)。',
183
- 'mode.selectedCount': '已勾选 {n} 个',
183
+ 'mode.selectedCount': '已勾选 {n} / {total}',
184
184
  'mode.sectionsHint': '默认可同步的是可移植分区;平台相关分区(MCP 服务器)与设备相关分区(插件文件、凭据状态、历史会话)只有你在这里主动勾选才参与同步。',
185
185
  'mode.atLeastOne': '请至少勾选一个同步分区',
186
186
  'mode.sectionPortable': '可移植',
@@ -198,7 +198,7 @@ export const zh = {
198
198
  'mode.sessionsWorkspaceWarn': '已勾选历史对话,但没有勾选「工作区」分区:目标机上这些对话可能归属不到任何工作区而显示不出来。建议一并勾选「工作区」。',
199
199
  'mode.gistSessionsWarn': '当前通道是 GitHub Gist,且勾选了「历史会话」:gist 会一次取回全部文件内容、单文件超限即截断、文件数还有上限,大分区同步可能失败或内容被截断。建议改用 Git / WebDAV / 对象存储通道同步历史会话,或取消勾选该分区。',
200
200
  'mode.persistHint': '分区选择已保存到本机:自动同步和手动推送都会使用此配置(重启后仍生效)。',
201
- 'mode.choose': '选择…',
201
+ 'mode.choose': '选择',
202
202
  'mode.pickerTitle': '选择同步分区',
203
203
  'mode.pickerHint': '改动即时生效并保存(关闭弹窗即完成)。',
204
204
  'mode.pickerBack': '返回分区列表',
@@ -485,7 +485,7 @@ export const en: Record<keyof typeof zh, string> = {
485
485
  'channel.gist': 'GitHub Gist',
486
486
  'channel.perChannelHint': 'Auto sync, sync mode, encryption and remote snapshots are configured independently for each channel (Git / WebDAV / object storage / Gist).',
487
487
  // Channel entry card (dialog-driven: click the button → configure the channel in the dialog)
488
- 'channel.open': 'Configure…',
488
+ 'channel.open': 'Configure',
489
489
  'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server.',
490
490
  // Disconnect channel config (user report: once configured there was no way out)
491
491
  'channel.expand': 'Expand channel details',
@@ -559,7 +559,7 @@ export const en: Record<keyof typeof zh, string> = {
559
559
  'repoPicker.empty': 'No private repository available',
560
560
  'repoPicker.loadFailed': 'Could not load the repository list — retry, or type the address below.',
561
561
  'repoPicker.updatedAt': 'Updated {time}',
562
- 'repoPicker.create': '+ New private repository…',
562
+ 'repoPicker.create': '+ New private repository',
563
563
  'repoPicker.createTitle': 'Create a private repository',
564
564
  'repoPicker.name': 'Repository name',
565
565
  'repoPicker.namePlaceholder': 'e.g. dsh-configs',
@@ -593,24 +593,24 @@ export const en: Record<keyof typeof zh, string> = {
593
593
  'syncflow.adoptHint': 'Check = import item; uncheck = skip',
594
594
  'syncflow.needsReviewBadge': 'Needs decision',
595
595
  'syncflow.empty': 'Remote snapshot matches local (no changes)',
596
- 'syncflow.listScope': '{review} item(s) listed for your decision · the other {auto} adopted by default',
596
+ 'syncflow.listScope': '{review} {review, plural, item|items} listed for your decision · the other {auto} adopted by default',
597
597
  'syncflow.keepLocalAll': 'Keep all current',
598
598
  'syncflow.useRemoteAll': 'Use all backup',
599
- 'syncflow.bulkHint': 'Bulk applies to {n} item(s) above',
599
+ 'syncflow.bulkHint': 'Bulk applies to {n} {n, plural, item|items} above',
600
600
  'syncflow.bulkErrorNote': 'error items need individual handling',
601
601
  'syncflow.conflictUseLocal': 'Keep current',
602
602
  'syncflow.conflictUseRemote': 'Use backup',
603
603
  'syncflow.conflictUnresolved': 'Conflict item {itemId} has no resolution yet (keep current / use backup)',
604
604
  'syncflow.diff': 'Diff',
605
- 'syncflow.importDone': 'Imported {n} section(s)',
605
+ 'syncflow.importDone': 'Imported {n} {n, plural, section|sections}',
606
606
  'syncflow.importFailed': 'Import failed (rolled back)',
607
607
  'syncflow.importedSections': 'Written',
608
608
  'syncflow.importWarnings': 'Import warnings',
609
609
  'syncflow.rollback': 'Rollback',
610
610
  'syncflow.rollbackDone': 'Rolled back',
611
611
  // Honesty (2026-10): a partial rollback is never reported as "rolled back"
612
- 'syncflow.rollbackPartialToast': 'Rollback incomplete: {n} item(s) could not be restored',
613
- 'syncflow.rollbackIncomplete': 'Rollback incomplete: {n} item(s) could not be restored automatically (the configuration may be left partially changed)',
612
+ 'syncflow.rollbackPartialToast': 'Rollback incomplete: {n} {n, plural, item|items} could not be restored',
613
+ 'syncflow.rollbackIncomplete': 'Rollback incomplete: {n} {n, plural, item|items} could not be restored automatically (the configuration may be left partially changed)',
614
614
  'syncflow.rollbackFailedItems': 'Items not rolled back (need manual handling)',
615
615
  'syncflow.importRollbackIncomplete': 'Import failed and the overall rollback did not complete: some changes could not be restored (see the warnings below)',
616
616
  'syncflow.needsRestart': 'Some changes require a DSH restart to take effect',
@@ -626,7 +626,7 @@ export const en: Record<keyof typeof zh, string> = {
626
626
  // Sync mode (default quick export / advanced custom export)
627
627
  'mode.title': 'Sync Sections',
628
628
  'mode.hint': 'Sync scope = the ticked set of sections (recommended sections are ticked by default and can be changed any time).',
629
- 'mode.selectedCount': '{n} selected',
629
+ 'mode.selectedCount': '{n} / {total} selected',
630
630
  'mode.sectionsHint': 'Portable sections sync by default; platform-dependent sections (MCP servers) and device-specific sections (plugin files, credentials status, sessions) only participate when you explicitly tick them here.',
631
631
  'mode.atLeastOne': 'Select at least one section to sync',
632
632
  'mode.sectionPortable': 'Portable',
@@ -637,14 +637,14 @@ export const en: Record<keyof typeof zh, string> = {
637
637
  'mode.sessionsLimit': 'How many recent sessions to sync',
638
638
  'mode.sessionsLimitHint': 'Session content is sensitive, so only the latest 5 are included by default (0 = select the section but carry no sessions); the unit is a session directory, so all logs of one session travel together.',
639
639
  'mode.sessionsPick': 'Pick specific conversations',
640
- 'mode.sessionsPickCount': '{n} conversation(s) picked',
640
+ 'mode.sessionsPickCount': '{n} {n, plural, conversation|conversations} picked',
641
641
  'mode.sessionsPickTitle': 'Pick conversations to sync',
642
642
  'mode.sessionsPickHint': 'Only the picked conversations are synced (this wins over the count above). Picking none returns to the "latest N" mode.',
643
643
  'mode.sessionsPickReset': 'Use "latest N" instead',
644
644
  'mode.sessionsWorkspaceWarn': 'Conversations are selected but the "Workspaces" section is not: on the target machine they may not be attributed to any workspace and stay invisible. Selecting "Workspaces" too is recommended.',
645
645
  'mode.gistSessionsWarn': 'The current channel is GitHub Gist and "Conversations" is selected: a gist returns all file content in one request, truncates any oversized single file, and caps the number of files, so syncing a large section may fail or be truncated. Use a Git / WebDAV / object-storage channel for conversations, or clear that section.',
646
646
  'mode.persistHint': 'The section selection is saved locally: both auto sync and manual push use this configuration (persists across restarts).',
647
- 'mode.choose': 'Choose…',
647
+ 'mode.choose': 'Choose',
648
648
  'mode.pickerTitle': 'Choose Sync Sections',
649
649
  'mode.pickerHint': 'Changes apply and are saved immediately (closing the dialog finishes the task).',
650
650
  'mode.pickerBack': 'Back to sections',
@@ -699,7 +699,7 @@ export const en: Record<keyof typeof zh, string> = {
699
699
  'autosync.partial': 'Partial',
700
700
  // issue #79: surface the skip reason in the status line (previously info-level log only)
701
701
  'autosync.skipReasonLabel': 'Skip reason',
702
- 'autosync.skipReasonConflictCount': '{reason} ({n} more section(s) on the remote need confirmation — handle them on the sync page)',
702
+ 'autosync.skipReasonConflictCount': '{reason} ({n} more {n, plural, section|sections} on the remote need confirmation — handle them on the sync page)',
703
703
  'autosync.lastMessage': 'Last message',
704
704
  'history.title': 'Sync History',
705
705
  'history.empty': 'No sync history yet',
@@ -753,8 +753,8 @@ export const en: Record<keyof typeof zh, string> = {
753
753
  'toast.snapshotsLoadFailed': 'Failed to load remote snapshots',
754
754
  'toast.snapshotsRefreshed': 'Remote snapshot list refreshed',
755
755
  // issue #59: remote snapshots that exist but cannot be read (skipping is defensive, never silent)
756
- 'toast.snapshotsUnreadable': '{n} remote snapshot(s) could not be read (list is incomplete) — see the note below',
757
- 'snapshots.unreadableHint': '{n} remote snapshot(s) cannot be parsed and are missing from this list: {files}',
756
+ 'toast.snapshotsUnreadable': '{n} remote {n, plural, snapshot|snapshots} could not be read (list is incomplete) — see the note below',
757
+ 'snapshots.unreadableHint': '{n} remote {n, plural, snapshot|snapshots} cannot be parsed and are missing from this list: {files}',
758
758
  // Page-level load failure (M-16: inline error state; loadError had no render point before)
759
759
  'load.failed': 'Failed to load sync status',
760
760
  'common.close': 'Close',
@@ -765,8 +765,8 @@ export const en: Record<keyof typeof zh, string> = {
765
765
  'common.loading': 'Loading…',
766
766
  'common.infoHint': 'Show description',
767
767
  'common.cancel': 'Cancel',
768
- 'syncflow.importPartialToast': 'Partly successful: {n} item(s) did not take effect — check the result details',
769
- 'syncflow.importPartial': 'Partly successful: {n} item(s) did not take effect (the rest were written; nothing rolled back)',
768
+ 'syncflow.importPartialToast': 'Partly successful: {n} {n, plural, item did|items did} not take effect — check the result details',
769
+ 'syncflow.importPartial': 'Partly successful: {n} {n, plural, item did|items did} not take effect (the rest were written; nothing rolled back)',
770
770
  'syncflow.ineffectiveItems': 'Items that did not take effect',
771
771
  'history.remoteMovedAction': 'View remote snapshots in Library',
772
772
  'syncflow.compat.excellent': 'Compatibility: excellent',
@@ -855,8 +855,8 @@ export const en: Record<keyof typeof zh, string> = {
855
855
  'merge.reason.format': 'This content format does not support auto-merge',
856
856
  'merge.reason.array': 'Arrays cannot be merged position by position',
857
857
  'merge.reason.section': 'The whole section conflicts',
858
- 'merge.conflictCount': '{n} conflict(s)',
859
- 'merge.autoMergedCount': '{n} change(s) merged automatically',
858
+ 'merge.conflictCount': '{n} {n, plural, conflict|conflicts}',
859
+ 'merge.autoMergedCount': '{n} {n, plural, change|changes} merged automatically',
860
860
  'merge.col.local': 'Local',
861
861
  'merge.col.remote': 'Remote',
862
862
  'merge.col.ancestor': 'Ancestor',
@@ -872,8 +872,8 @@ export const en: Record<keyof typeof zh, string> = {
872
872
  'merge.bulk.hint': '"Use auto-merge for all" only affects sections that really have an auto-merged result; "Leave conflicts for next sync" moves every section that needs your decision out of this run (nothing is written, the baseline does not advance) — they come back on the next sync.',
873
873
  'merge.bulk.title': 'Bulk',
874
874
  'merge.apply': 'Apply merge',
875
- 'merge.apply.summary': '{decided} of {needs} section(s) decided',
876
- 'merge.apply.willApply': '{n} section(s) will be applied now',
875
+ 'merge.apply.summary': '{decided} of {needs} {needs, plural, section|sections} decided',
876
+ 'merge.apply.willApply': '{n} {n, plural, section|sections} will be applied now',
877
877
  'merge.result.title': 'Merge result',
878
878
  'merge.result.applied': 'Applied sections',
879
879
  'merge.result.skipped': 'Skipped',
@@ -881,7 +881,7 @@ export const en: Record<keyof typeof zh, string> = {
881
881
  'merge.result.restoreId': 'Restore point',
882
882
  'merge.result.rolledBack': 'Rolled back in full',
883
883
  'merge.result.rollbackIncomplete': 'Rollback was incomplete: some items were not restored (see warnings)',
884
- 'merge.result.pending': '{n} section(s) still need a decision (skip is not resolved, so they will come back on the next sync)',
884
+ 'merge.result.pending': '{n} {n, plural, section|sections} still need a decision (skip is not resolved, so they will come back on the next sync)',
885
885
  'merge.result.rollback': 'Roll back',
886
886
  'merge.result.rolledBackTo': 'Rolled back to {id}',
887
887
  'merge.result.failed': '{n} failed',
@@ -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 { CHECKSUMS_FILE, MANIFEST_FILE, parseManifest } from '../schema/manifest.ts';
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. 完整性(integrity/checksums.json 逐一 SHA-256)
306
+ // 4. 完整性(checksums.json 逐一 SHA-256)+ 4b 反向覆盖(表外条目):判据下沉在 security 侧。
301
307
  const zipWarnings: string[] = [];
302
- let checksums = { ok: true, mismatches: [] as string[], missing: [] as string[] };
303
- // 4a. H2:校验表**缺失**或**为空**时,全部条目都处于「未登记 ⇒ 未被校验」状态。
304
- // 此前整段完整性逻辑(含下面的反向检查)都嵌在 `if (archive.has(CHECKSUMS_FILE))` 内,
305
- // 于是「剥掉 checksums.json」或「把它置为 {}」= 一个条目都不校验,却 valid=true / 零告警。
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
- const warnings = [...zipWarnings, ...bundle.migrationWarnings, ...analyzed.sectionWarnings];
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
- if (outcome.executed.status === 'failed') anyFailed = true;
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 && outcome.executed.status === 'failed') break;
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, verifyAgainstTable,
1477
+ usableSecretValue,
1497
1478
  } from './import-planning.ts';
1498
1479
 
1499
1480
  // 对外导入面保持不变(core/index.ts 与既有测试按 './analyzer.ts' 取这三个名字)。
@@ -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 { FileSnapshotStore, createSnapshot, normalizeDshVersion, resolveFileTarget, verifySnapshot } from './backup.ts';
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
+