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
@@ -593,6 +593,53 @@ export type PruneSelector = (
593
593
  export const fallbackPruneSelector: PruneSelector = (metas, policy) =>
594
594
  selectPruneCandidates(metas, policy.keepLast);
595
595
 
596
+ /* ---------------- BK-05:反悔副本(pre-restore)保留策略 ----------------
597
+ *
598
+ * 反悔副本 = 恢复链路在覆盖/删除前落的原文副本(restore.ts:888 起写 <snapshotDir>/pre-restore/<attempt>/)。
599
+ * 它与快照本体的生命周期**不是一回事**:本体可以按保留策略回收(历史回滚点),
600
+ * 反悔副本却是用户「把刚才这次恢复撤回去」的最后一份原文,整条链路上没有第二份
601
+ * (不在 blobs 里、不进同步、不进导出)。所以两套策略分开:
602
+ * - 快照本体:既有 RetentionPolicyLike(宿主可注入 GFS);
603
+ * - 反悔副本:只按「份数 + 天数」淘汰,常量在 core 内固定(无宿主可调项 —— 少了它等于没有反悔能力)。
604
+ */
605
+ export const PRE_RESTORE_KEEP_ATTEMPTS = 5;
606
+ export const PRE_RESTORE_KEEP_DAYS = 30;
607
+ /** 反悔副本目录名(restore.ts 写入侧同一常量值;这里是读侧,绝不与写侧分叉) */
608
+ const PRE_RESTORE_DIR_NAME = 'pre-restore';
609
+
610
+ /** 解析反悔副本 attempt 目录名里的时间戳(本地时间 yyyyMMdd-HHmmss-SSS,可选 -N 去重后缀)。 */
611
+ function parsePreRestoreStamp(name: string): number | null {
612
+ const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(\d{3})(?:-\d+)?$/.exec(name);
613
+ if (m === null) return null;
614
+ const at = new Date(
615
+ Number(m[1]), Number(m[2]) - 1, Number(m[3]),
616
+ Number(m[4]), Number(m[5]), Number(m[6]), Number(m[7]),
617
+ ).getTime();
618
+ return Number.isFinite(at) ? at : null;
619
+ }
620
+
621
+ /** 一条反悔副本 attempt 的淘汰计划(删除**前**就进 PrunePlan,供审计与界面展示)。 */
622
+ export interface PrunePreRestoreDrop {
623
+ snapshotId: string;
624
+ attempt: string;
625
+ /** count = 超出份数上限;over-age = 超过保留天数 */
626
+ reason: 'count' | 'over-age';
627
+ /** 该 attempt 目录里的文件数(用户能看出「删了多少份原文」) */
628
+ files: number;
629
+ }
630
+
631
+ /** 一轮保留清理的**计划**(在真正 rm 之前回调出去;也是 pruneNow 的返回值)。 */
632
+ export interface PrunePlan {
633
+ /** 将回收的快照 id(目录内的快照本体,不含 pre-restore 子树) */
634
+ removeSnapshots: string[];
635
+ /** 因存在反悔副本而**不**整目录删的快照 id(只删本体内容、留下 pre-restore/) */
636
+ keepPreRestoreFor: string[];
637
+ /** 反悔副本 attempt 级淘汰计划 */
638
+ dropPreRestore: PrunePreRestoreDrop[];
639
+ /** 计划删除的具体路径(相对快照根、'/' 分隔;审计「到底删了什么」) */
640
+ paths: string[];
641
+ }
642
+
596
643
  export interface FileSnapshotStoreOptions {
597
644
  /** 快照根目录(宿主决定,如 ~/.dsh/dsh-config-manager/snapshots) */
598
645
  dir: string;
@@ -608,6 +655,8 @@ export interface FileSnapshotStoreOptions {
608
655
  * 缺省 = 不记录(不改变 prune 行为、不污染核心引擎)。
609
656
  */
610
657
  onPrune?: (removedIds: string[]) => void;
658
+ /** BK-05:保留清理的**计划**回调(在真正删除之前,best-effort;抛错不影响清理) */
659
+ onPrunePlan?: (plan: PrunePlan) => void;
611
660
  /**
612
661
  * m-retention:快照保留策略提供者(GFS 分层;可配置)。
613
662
  * 缺省 = `SNAPSHOT_RETENTION_LIMIT`(= 既有的「保留最近 10 个」,行为不变)。
@@ -722,7 +771,8 @@ export class FileSnapshotStore implements SnapshotStore {
722
771
  * 自动淘汰,只能手动删除(deleteSnapshot)。
723
772
  * Phase 4 F3:被 active/quarantine 未收敛 journal 引用的 snapshot(recovery 回滚点)
724
773
  * 必须豁免——引用提供者(referencedSnapshotIds)返回的 id 绝不自动清理。 */
725
- private async prune(): Promise<void> {
774
+ private async prune(): Promise<PrunePlan> {
775
+ const emptyPlan: PrunePlan = { removeSnapshots: [], keepPreRestoreFor: [], dropPreRestore: [], paths: [] };
726
776
  const dir = this.options.dir;
727
777
  const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
728
778
  // F3:被未收敛 journal 引用的快照**绝不自动清理**。判据是「读不到 ≠ 没有引用」——
@@ -734,7 +784,7 @@ export class FileSnapshotStore implements SnapshotStore {
734
784
  referenced = await this.options.referencedSnapshotIds();
735
785
  } catch (err) {
736
786
  this.options.onWarn?.(engineMsg('core.backup.pruneAbandoned', { reason: err instanceof Error ? err.message : String(err) }));
737
- return;
787
+ return emptyPlan;
738
788
  }
739
789
  }
740
790
  // m-retention:策略读取失败 → 回退缺省(绝不因策略提供者异常而误删/崩溃)
@@ -762,17 +812,108 @@ export class FileSnapshotStore implements SnapshotStore {
762
812
  // 损坏 / 非快照目录:跳过(与 listSnapshots 语义一致)
763
813
  }
764
814
  }
765
- const removedIds: string[] = [];
815
+ // --- BK-05:先出计划,再删 ---
816
+ // 快照本体与反悔副本是两套生命周期:本体按保留策略回收,pre-restore 按「份数 + 天数」淘汰。
817
+ // 计划必须在 rm **之前**成型并回调出去——删完再报等于审计拿不到原始状态。
818
+ const plan: PrunePlan = {
819
+ removeSnapshots: [],
820
+ keepPreRestoreFor: [],
821
+ dropPreRestore: await this.collectPreRestorePlan(),
822
+ paths: [],
823
+ };
824
+ /** 每个待回收快照:目录名 → 要删的子项(保持 pre-restore 不动) */
825
+ const removals: { id: string; children: string[]; hasPre: boolean }[] = [];
766
826
  for (const id of selector(metas, policy)) {
767
827
  const target = path.join(dir, id);
768
828
  if (!target.startsWith(dir)) continue; // 越界 id 跳过(不删、不抛,同 save/readBlob 包含性约定)
829
+ const children = await fs.readdir(target).catch(() => [] as string[]);
830
+ const hasPre = children.includes(PRE_RESTORE_DIR_NAME);
831
+ removals.push({ id, children, hasPre });
832
+ plan.removeSnapshots.push(id);
833
+ for (const child of children) {
834
+ if (child === PRE_RESTORE_DIR_NAME) continue;
835
+ plan.paths.push(id + '/' + child);
836
+ }
837
+ }
838
+ for (const drop of plan.dropPreRestore) plan.paths.push(drop.snapshotId + '/pre-restore/' + drop.attempt + '/');
839
+ this.options.onPrunePlan?.(plan);
840
+ if (plan.removeSnapshots.length === 0 && plan.dropPreRestore.length === 0) return plan;
841
+
842
+ // 1) 回收快照本体:有反悔副本的快照**不整目录删**(否则唯一那份原文跟着消失),逐项删其余内容
843
+ const removedIds: string[] = [];
844
+ for (const removal of removals) {
845
+ const target = path.join(dir, removal.id);
846
+ for (const child of removal.children) {
847
+ if (child === PRE_RESTORE_DIR_NAME) continue;
848
+ await fs.rm(path.join(target, child), { recursive: true, force: true });
849
+ }
850
+ if (removal.hasPre) plan.keepPreRestoreFor.push(removal.id);
851
+ else await fs.rm(target, { recursive: true, force: true });
852
+ removedIds.push(removal.id);
853
+ }
854
+ // 2) 反悔副本 attempt 级淘汰(份数超限 / 超过保留天数)
855
+ for (const drop of plan.dropPreRestore) {
856
+ await fs.rm(path.join(dir, drop.snapshotId, PRE_RESTORE_DIR_NAME, drop.attempt), { recursive: true, force: true });
857
+ }
858
+ // 3) 收尾:快照本体已回收(无 snapshot.json)且反悔副本也空了 ⇒ 不留空壳目录
859
+ const touched = new Set<string>([...plan.keepPreRestoreFor, ...plan.dropPreRestore.map((d) => d.snapshotId)]);
860
+ for (const id of touched) {
861
+ const target = path.join(dir, id);
862
+ const hasSnapshot = await fs.stat(path.join(target, 'snapshot.json')).catch(() => null) !== null;
863
+ if (hasSnapshot) continue;
864
+ const left = await fs.readdir(path.join(target, PRE_RESTORE_DIR_NAME)).catch(() => null);
865
+ if (left !== null && left.length > 0) continue;
769
866
  await fs.rm(target, { recursive: true, force: true });
770
- removedIds.push(id);
771
867
  }
772
868
  // Phase 6:自动保留清理历史回调(best-effort;不改变 prune 行为)
773
869
  if (removedIds.length > 0) {
774
870
  try { this.options.onPrune?.(removedIds); } catch { /* 历史回调失败不阻断 */ }
775
871
  }
872
+ return plan;
873
+ }
874
+
875
+ /**
876
+ * BK-05:反悔副本(pre-restore)淘汰计划 —— 只看**份数与天数**,与快照本体的保留策略无关。
877
+ *
878
+ * 为什么单独一套:快照本体是「历史回滚点」,可被 GFS 策略批量回收;反悔副本是用户把
879
+ * 刚才那次恢复撤回去的**唯一**原文(不在 blobs、不进同步、不进导出),它只需要「别无限长」,
880
+ * 不需要也不该受 keepLast/keepMonthly 影响 —— 否则一次批量淘汰会把最近几次恢复的反悔点一起抹掉。
881
+ * attempt 名即恢复时间戳(restore.ts 的 preRestoreStamp,本地时间 yyyyMMdd-HHmmss-SSS[-序号]);
882
+ * 解析不出来回落目录 mtime(手工改名的残留也能被淘汰,不会永久占位)。
883
+ */
884
+ private async collectPreRestorePlan(): Promise<PrunePreRestoreDrop[]> {
885
+ const dir = this.options.dir;
886
+ const drops: PrunePreRestoreDrop[] = [];
887
+ const now = Date.now();
888
+ const maxAgeMs = PRE_RESTORE_KEEP_DAYS * 24 * 60 * 60 * 1000;
889
+ for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
890
+ if (!entry.isDirectory()) continue;
891
+ const preRoot = path.join(dir, entry.name, PRE_RESTORE_DIR_NAME);
892
+ const attempts = await fs.readdir(preRoot, { withFileTypes: true }).catch(() => null);
893
+ if (attempts === null) continue;
894
+ const rows: { attempt: string; at: number; files: number }[] = [];
895
+ for (const a of attempts) {
896
+ if (!a.isDirectory()) continue;
897
+ const at = parsePreRestoreStamp(a.name)
898
+ ?? (await fs.stat(path.join(preRoot, a.name)).then((s) => s.mtimeMs).catch(() => now));
899
+ const names = await fs.readdir(path.join(preRoot, a.name), { recursive: true }).catch(() => [] as string[]);
900
+ rows.push({ attempt: a.name, at, files: names.length });
901
+ }
902
+ rows.sort((x, y) => y.at - x.at); // 新 → 旧
903
+ rows.forEach((row, index) => {
904
+ if (index >= PRE_RESTORE_KEEP_ATTEMPTS) {
905
+ drops.push({ snapshotId: entry.name, attempt: row.attempt, reason: 'count', files: row.files });
906
+ } else if (now - row.at > maxAgeMs) {
907
+ drops.push({ snapshotId: entry.name, attempt: row.attempt, reason: 'over-age', files: row.files });
908
+ }
909
+ });
910
+ }
911
+ return drops;
912
+ }
913
+
914
+ /** BK-05:手动触发一轮保留清理(与 save 后的自动清理同一实现)。 */
915
+ async pruneNow(): Promise<PrunePlan> {
916
+ return this.prune();
776
917
  }
777
918
 
778
919
  async load(id: string): Promise<Snapshot> {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 导入执行策略的**唯一缺省**(IE-02)。
3
+ *
4
+ * 为什么单独一个零依赖模块:这个缺省必须被四处引用 —— 引擎 `src/core/importer.ts`、
5
+ * HTTP 路由 `src/routes/import.ts`、UI 向导 `src/ui/import-wizard.ts`、同步通道
6
+ * `src/sync/sync-engine.ts`。而 `src/ui/import-wizard.ts` 会被 `src/client/run-store.ts`
7
+ * 作为**值**导入并打进浏览器 bundle,所以它不能从 `src/core/importer.ts` 取这个值
8
+ * (那条路径经 Analyzer 拖进 node 内建与 ZIP 依赖)。本文件零依赖,两端都能安全引入。
9
+ *
10
+ * 语义:任一计划项失败立即整体回滚(安全侧,也就是 UI 文案「失败时整体回滚(推荐)」)。
11
+ * 要「单项失败如实记录并继续其余项」必须显式传 `false`,不再靠各入口各写一份缺省。
12
+ */
13
+ export const DEFAULT_ROLLBACK_ON_ERROR = true;
@@ -11,6 +11,13 @@
11
11
  import type { ZipArchive, ZipSafetyLimits } from '../utils/zip.ts';
12
12
  import type { MsgFunc } from './messages.ts';
13
13
  import { Analyzer } from './analyzer.ts';
14
+ import { DEFAULT_ROLLBACK_ON_ERROR } from './import-defaults.ts';
15
+
16
+ /**
17
+ * 缺省值的规范定义在 `./import-defaults.ts`(零依赖,UI 向导与浏览器 bundle 也引入它);
18
+ * 这里再导出一份,宿主侧调用方(路由、同步通道、CLI)可以从引擎门面拿,不必记两个路径。
19
+ */
20
+ export { DEFAULT_ROLLBACK_ON_ERROR };
14
21
  import type { PlanItemProgress } from './analyzer.ts';
15
22
  import type { BootSafetyReport } from './boot-safety.ts';
16
23
  import type {
@@ -41,7 +48,10 @@ export interface ExecuteOptions {
41
48
  secretInputs?: Record<string, string>;
42
49
  /** 加密备份解密结果(仅内存;解密必须经 m4 encryption provider) */
43
50
  decryptedCredentials?: Map<string, string>;
44
- /** 任一项失败立即整体回滚(默认 false:单项失败如实记录并继续其余项) */
51
+ /**
52
+ * 任一项失败立即整体回滚。缺省 = `DEFAULT_ROLLBACK_ON_ERROR`(`./import-defaults.ts`,全仓唯一一份);
53
+ * 要「单项失败如实记录并继续其余项」必须显式传 `false`。
54
+ */
45
55
  rollbackOnError?: boolean;
46
56
  /** m1:每完成一个计划项的进度回调(透传给 Analyzer;不传则无埋点) */
47
57
  onItem?: (info: PlanItemProgress) => void;
@@ -97,7 +107,7 @@ export class Importer {
97
107
  confirm: opts.confirm,
98
108
  secretInputs: opts.secretInputs,
99
109
  decryptedCredentials: opts.decryptedCredentials,
100
- rollbackOnError: opts.rollbackOnError,
110
+ rollbackOnError: opts.rollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR,
101
111
  onItem: opts.onItem,
102
112
  onItemStart: opts.onItemStart,
103
113
  onLog: opts.onLog,
@@ -0,0 +1,205 @@
1
+ /**
2
+ * SEC-05 回归:校验表只做「正向」校验 —— 表里登记的逐条比对,**表外的归档条目没人管**。
3
+ * 文件类分区又是按归档名原样落盘的,于是「ZIP 有、checksums 没登记」的文件照样被写进目标目录,
4
+ * 完整性声明形同虚设(审计 docs/research/dsh-config-manager-technical-audit.md SEC-05)。
5
+ *
6
+ * 修后口径:
7
+ * - 会落盘的文件分区条目(Create/Update/Conflict)若不在 checksums 表内 ⇒ 计划项升级为 Error(逐条零写入),
8
+ * 整包仍 valid(表外 ≠ 被篡改,只留一条 warning);
9
+ * - 校验表**缺失或为空** ⇒ 一个条目都没被校验,文件分区条目同样逐条拦(RV1-F1,否则删表即绕过);
10
+ * - 历史包兼容开关 extraEntryExemptSections(默认空)按分区豁免,豁免分区退回「只告警」旧行为;
11
+ * - 「不在任何文件分区前缀下」的散落条目仍是告警(守 tests/conformance/roundtrip.test.ts INT-01)。
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import fs from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ import { Analyzer } from './analyzer.ts';
20
+ import { Importer } from './importer.ts';
21
+ import { createAdapters } from '../adapters/index.ts';
22
+ import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
23
+ import { writeZip } from '../utils/zip.ts';
24
+ import { buildManifest } from '../schema/manifest.ts';
25
+ import { buildChecksums } from '../utils/hashing.ts';
26
+ import { SECTION_IDS, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.ts';
27
+ import { UNVERIFIED_FILE_ENTRY_CODE } from '../security/integrity.ts';
28
+ import type { SectionId } from '../schema/types.ts';
29
+ import type { HostContext } from './types.ts';
30
+
31
+ const SETTINGS = JSON.stringify({
32
+ version: 1,
33
+ namespaces: { llm: { value: { model: 'deepseek-chat' }, revision: 1, secrets: [] } },
34
+ });
35
+ const DECISIONS = { strategy: 'merge' as const, resolutions: {}, pathMappings: [] };
36
+ /** skills 分区在 ZIP 内的真实前缀(custom/skills/),从注册表派生,不写死布局。 */
37
+ const SKILL_PREFIX = SECTION_FILE_PREFIXES['skills']!;
38
+ const SKILL_REL = 'demo/SKILL.md';
39
+ const SKILL_ENTRY = SKILL_PREFIX + SKILL_REL;
40
+ /** 不在任何文件分区前缀下的散落条目(INT-01 用)。 */
41
+ const ROGUE_ENTRY = 'injected/rogue.json';
42
+
43
+ /** 造包:settings + skills 声明为包含;unlisted 里的条目进 ZIP 但不进 checksums 表。 */
44
+ async function buildBundle(
45
+ opts: { unlisted?: string[]; declareSkills?: boolean; checksums?: 'omit' | 'empty' } = {},
46
+ ): Promise<{ zipPath: string; tmp: string }> {
47
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcm-sec05-'));
48
+ const flags: Record<string, boolean> = {};
49
+ for (const sid of SECTION_IDS) flags[sid] = false;
50
+ flags['settings'] = true;
51
+ flags['skills'] = opts.declareSkills !== false;
52
+ const manifest = buildManifest({
53
+ exporterVersion: '0.1.0',
54
+ dshVersion: '0.1.54',
55
+ platform: 'win32',
56
+ arch: 'x64',
57
+ sections: flags as Record<SectionId, boolean>,
58
+ containsSecrets: false,
59
+ encrypted: false,
60
+ encryption: null,
61
+ });
62
+ const contentEntries = [
63
+ { name: SECTION_JSON_PATHS['settings']!, data: Buffer.from(SETTINGS, 'utf8') },
64
+ { name: SKILL_ENTRY, data: Buffer.from('# demo skill', 'utf8') },
65
+ { name: ROGUE_ENTRY, data: Buffer.from('{"rogue":true}', 'utf8') },
66
+ ];
67
+ const unlisted = new Set(opts.unlisted ?? []);
68
+ const checksums = buildChecksums(contentEntries.filter((e) => !unlisted.has(e.name)));
69
+ const entries = [
70
+ ...contentEntries,
71
+ { name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest), 'utf8') },
72
+ // RV1-F1:表缺失('omit')与表为空('empty')都必须逐条 fail-closed —— 两种形态曾被当成「没事」。
73
+ ...(opts.checksums === 'omit'
74
+ ? []
75
+ : [{
76
+ name: 'integrity/checksums.json',
77
+ data: Buffer.from(opts.checksums === 'empty' ? '{}' : JSON.stringify(checksums), 'utf8'),
78
+ }]),
79
+ ];
80
+ const zipPath = path.join(tmp, 'bundle.zip');
81
+ await writeZip(zipPath, entries);
82
+ return { zipPath, tmp };
83
+ }
84
+
85
+ function mkCtx(): HostContext {
86
+ return makeContext('linux', '/home/bob');
87
+ }
88
+
89
+ function mkAnalyzer(ctx: HostContext, extraEntryExemptSections?: readonly SectionId[]): Analyzer {
90
+ return new Analyzer({
91
+ ctx,
92
+ adapters: createAdapters({ namespaces: ['llm'] }),
93
+ snapshotStore: new MemSnapshotStore(),
94
+ ...(extraEntryExemptSections ? { extraEntryExemptSections } : {}),
95
+ });
96
+ }
97
+
98
+ function mkImporter(ctx: HostContext): Importer {
99
+ return new Importer({ ctx, adapters: createAdapters({ namespaces: ['llm'] }), snapshotStore: new MemSnapshotStore() });
100
+ }
101
+
102
+ test('SEC-05/① 对照:文件分区条目全量登记 → 不阻断,计划项照常 Create', async () => {
103
+ const { zipPath } = await buildBundle();
104
+ const ctx = mkCtx();
105
+ const importer = mkImporter(ctx);
106
+ const analysis = await importer.analyzeImport(zipPath);
107
+ assert.equal(analysis.valid, true, '全量登记不应阻断');
108
+ assert.deepEqual(analysis.errors, []);
109
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
110
+ const item = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
111
+ assert.ok(item, 'skills 分区应有 SKILL.md 计划项');
112
+ assert.equal(item!.kind, 'Create');
113
+ });
114
+
115
+ test('SEC-05/② 表外文件分区条目 → 计划项 Error(零写入),analysis 只留一条 warning', async () => {
116
+ const { zipPath } = await buildBundle({ unlisted: [SKILL_ENTRY] });
117
+ const ctx = mkCtx();
118
+ const importer = mkImporter(ctx);
119
+ const analysis = await importer.analyzeImport(zipPath);
120
+ // 表外 ≠ 被篡改:逐条 Error 已保证零写入,把整包判死只会拒绝整笔合法导入(含一个多余文件的历史包),
121
+ // 却没多拦住任何一次写盘 ⇒ valid 保持 true,错误面只留一条 warning 点名该条目。
122
+ assert.equal(analysis.valid, true, '表外条目不得把整包判死');
123
+ assert.deepEqual(analysis.errors, [], '表外条目不进 errors(不阻断整体导入)');
124
+ const named = analysis.warnings.some((w) => w.includes(SKILL_REL) || w.includes(SKILL_ENTRY));
125
+ assert.ok(named, 'warnings 必须点名该条目,实际: ' + JSON.stringify(analysis.warnings));
126
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
127
+ const item = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
128
+ assert.ok(item, '被阻断条目仍要出现在计划里(用户能看到为什么没导)');
129
+ assert.equal(item!.kind, 'Error');
130
+ assert.equal(item!.severity, 'error');
131
+ assert.ok(item!.detail?.includes(UNVERIFIED_FILE_ENTRY_CODE), 'detail 应带错误码,实际: ' + String(item!.detail));
132
+ });
133
+
134
+ test('SEC-05/③ 按分区豁免 → 退回只告警(历史包兼容开关,默认关)', async () => {
135
+ const { zipPath } = await buildBundle({ unlisted: [SKILL_ENTRY] });
136
+ const ctx = mkCtx();
137
+ const analyzer = mkAnalyzer(ctx, ['skills']);
138
+ const analysis = await analyzer.analyzeImport(zipPath);
139
+ assert.equal(analysis.valid, true, '豁免分区不得阻断');
140
+ assert.ok(analysis.warnings.some((w) => w.includes(SKILL_ENTRY)), '豁免也要留告警,不能静默放行');
141
+ const plan = await analyzer.createImportPlan(zipPath, DECISIONS);
142
+ const item = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
143
+ assert.equal(item!.kind, 'Create', '豁免分区应照常落盘');
144
+ });
145
+
146
+ test('SEC-05/④ 非文件分区的散落条目仍只告警(守 INT-01 口径)', async () => {
147
+ const { zipPath } = await buildBundle({ unlisted: [ROGUE_ENTRY] });
148
+ const ctx = mkCtx();
149
+ const analysis = await mkImporter(ctx).analyzeImport(zipPath);
150
+ assert.equal(analysis.valid, true, '散落条目不阻断');
151
+ assert.deepEqual(analysis.errors, []);
152
+ assert.ok(analysis.warnings.some((w) => w.includes(ROGUE_ENTRY)), '散落条目必须有告警(不得静默忽略)');
153
+ });
154
+
155
+ test('SEC-05/⑤ 执行被阻断的计划 → 表外文件零落盘', async () => {
156
+ const { zipPath } = await buildBundle({ unlisted: [SKILL_ENTRY] });
157
+ const ctx = mkCtx();
158
+ const importer = mkImporter(ctx);
159
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
160
+ const skillItem = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
161
+ assert.ok(skillItem, '被阻断条目应在计划里');
162
+ const result = await importer.executeImportPlan(zipPath, plan, { confirm: true });
163
+ const blocked = result.executed.find((e) => e.itemId === skillItem!.id);
164
+ assert.equal(blocked?.status, 'failed', '被阻断项执行结果必须是 failed');
165
+ assert.equal(await ctx.fs.exists(SKILL_ENTRY), false, '表外条目绝不能被写进目标');
166
+ });
167
+
168
+ /* ─── RV1-F1:表缺失 / 表为空 ⇒ 逐条 fail-closed(删掉表就等于关掉 SEC-05 的那个绕过) ─── */
169
+
170
+ async function expectFailClosed(mode: 'omit' | 'empty'): Promise<void> {
171
+ const { zipPath } = await buildBundle({ checksums: mode });
172
+ const ctx = mkCtx();
173
+ const importer = mkImporter(ctx);
174
+ const analysis = await importer.analyzeImport(zipPath);
175
+ // 逐条 fail-closed,不是整包拒:settings 这类非文件分区照常可导,判死的只是完整性管不到的文件分区条目。
176
+ assert.equal(analysis.valid, true, '表缺失/为空不得把整包判死(旧包仍要能用)');
177
+ assert.deepEqual(analysis.errors, []);
178
+ assert.ok(
179
+ analysis.warnings.some((w) => w.includes(SKILL_ENTRY)),
180
+ '没有校验表时,文件分区条目必须被点名(静默放行 = 防线失效),实际: ' + JSON.stringify(analysis.warnings),
181
+ );
182
+
183
+ const plan = await importer.createImportPlan(zipPath, DECISIONS);
184
+ const skillItem = plan.items.find((i) => i.adapter === 'skills' && i.target?.ref === SKILL_REL);
185
+ assert.ok(skillItem, '文件分区条目应在计划里');
186
+ assert.equal(skillItem!.kind, 'Error', '没有校验表 ⇒ 文件分区条目一律未覆盖 ⇒ 必须拦');
187
+ assert.ok(String(skillItem!.detail ?? '').includes(UNVERIFIED_FILE_ENTRY_CODE), '拦下时必须带错误码');
188
+ assert.ok(
189
+ plan.items.some((i) => i.adapter === 'settings' && i.kind !== 'Error'),
190
+ '非文件分区(settings)不应被连带阻断',
191
+ );
192
+
193
+ const result = await importer.executeImportPlan(zipPath, plan, { confirm: true });
194
+ assert.equal(result.executed.find((e) => e.itemId === skillItem!.id)?.status, 'failed', '被拦项执行结果必须是 failed');
195
+ assert.equal(await ctx.fs.exists(SKILL_ENTRY), false, '没有校验表时,文件条目绝不能被写进目标');
196
+ }
197
+
198
+ test('RV1/F1⑥ 包内缺 integrity/checksums.json → 文件分区条目逐条拦、零写入(删表不能绕过 SEC-05)', async () => {
199
+ await expectFailClosed('omit');
200
+ });
201
+
202
+ test('RV1/F1⑦ 校验表为空 {} → 与缺失同口径(空表 = 一个条目都没校验)', async () => {
203
+ await expectFailClosed('empty');
204
+ });
205
+
@@ -47,7 +47,9 @@ test('i18n(ui): zhUiT 缺省 + makeUiT 双语插值', () => {
47
47
  assert.equal(zhUiT('error.notMounted'), 'config-manager 服务未挂载(插件未加载):请确认 profile 中已安装 dsh-config-manager 并重启 DSH');
48
48
  const enT = makeUiT('en');
49
49
  assert.equal(enT('error.notMounted'), 'config-manager service is not mounted (plugin not loaded): make sure dsh-config-manager is installed in the profile and restart DSH');
50
- assert.equal(enT('report.credentialsNeedEntry', { count: '3' }), '3 credential(s) need to be entered');
50
+ // 2026-11-01 起复数由 `{count, plural, ...}` 段决定:3 → credentials / need
51
+ assert.equal(enT('report.credentialsNeedEntry', { count: '3' }), '3 credentials need to be entered');
52
+ assert.equal(enT('report.credentialsNeedEntry', { count: '1' }), '1 credential needs to be entered');
51
53
  // 未知键回退 zh → 键名(类型层不允许字面量,这里用宽松断言验证运行期回退)
52
54
  const loose = makeUiT('en') as (key: string) => string;
53
55
  assert.equal(loose('no.such.key'), 'no.such.key');
@@ -65,6 +65,7 @@ export const zh = {
65
65
  'import.unsupportedSectionVersion': '分区 {section} 的数据版本 {version} 高于本版本支持的 1(已跳过该分区)',
66
66
  'import.checksumsMissing': '备份未提供完整性校验表,全部条目未被校验',
67
67
  'import.extraEntries': 'ZIP 内含未登记进校验表的条目: {entries}(未被校验)',
68
+ 'import.unverifiedFileEntry': '以下条目不在校验表内,无法校验,已逐条阻断写入(其余条目照常导入): {entries}',
68
69
  'import.undeclaredSectionContent': '备份包含未声明的分区内容(清单里标为不包含,这些条目不会被导入): {sections};示例条目: {entries}',
69
70
  'import.secretsPayloadEmpty': '备份声明携带凭据值,但解出的凭据清单为空:这不是「没有凭据」,而是没读到(请确认备份密码与导出机的凭据库布局)',
70
71
  'import.migrated': '备份 schema 已从 v{from} 迁移到 v{to}',
@@ -349,6 +350,7 @@ export const zh = {
349
350
  'adapter.workspaceSessionsOutsideBundle': '另有 {count} 个会话不在此备份包内(本次未导入其数据),未登记;目标机本来就有这些会话则不受影响',
350
351
  'adapter.workspaceSessionsManual': '本机工作区服务不支持会话登记,{count} 个会话未登记:启动 DSH 后可在设置里重新导入,或用离线修复工具 sessions repair',
351
352
  'adapter.workspaceSessionsOwnerMissing': '找不到对应的目标工作区记录,{count} 个会话未登记:请确认备份里包含该工作区(导出会话时会自动连带其工作区)',
353
+ 'adapter.selfImportBlocked': '包内含 {path}(同步目标配置):已拒绝写入,本机同步目标保持不变',
352
354
  'adapter.providerMissing': '导入数据缺少 provider {route}',
353
355
  'adapter.namespaceMissing': '导入数据缺少 namespace {ref}',
354
356
 
@@ -399,6 +401,7 @@ export const zh = {
399
401
  'sync.changesSummary': 'compatibility={compatibility},共 {count} 项变更',
400
402
  'sync.configWhitespace': '仓库地址不能包含空白字符',
401
403
  'sync.configUnparseable': '无法解析仓库地址: {url}',
404
+ 'sync.configPrivateHost': '仓库地址指向本机或内网地址,不作为同步目标: {url}',
402
405
  'sync.configUserinfo': '请勿在仓库地址中包含用户名/密码(认证 token 请使用凭据字段,不会拼入地址)',
403
406
  'sync.state.notObject': 'sync-state.json 损坏:必须是对象',
404
407
  'sync.state.schemaUnsupported': 'sync-state.json schemaVersion 不支持: {version}(期望 {expected})',
@@ -459,6 +462,7 @@ export const zh = {
459
462
  'sync.webdav.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}',
460
463
  'sync.pruneListFailed': '远端快照裁剪失败:无法列出远端快照,{reason}',
461
464
  'sync.pruneDeleteFailed': '远端快照裁剪失败:删除快照 {id} 失败,{reason}',
465
+ 'sync.gcSkipped': '本轮已放弃回收远端孤立文件({code}):本轮不做回收,快照本身不受影响',
462
466
 
463
467
  // ---------- 配置市场(m-market:只读公开仓库目录读取 + 校验) ----------
464
468
  'market.invalidItemId': '非法市场条目 id: {id}(仅允许字母数字开头,字符限 . _ -)',
@@ -571,6 +575,7 @@ export const en: Record<keyof typeof zh, string> = {
571
575
  'import.unsupportedSectionVersion': 'Section {section} data version {version} is higher than the supported version 1 (section skipped)',
572
576
  'import.checksumsMissing': 'Backup provides no integrity checksum table; no entry was verified',
573
577
  'import.extraEntries': 'ZIP contains entries not registered in the checksum table: {entries} (not verified)',
578
+ 'import.unverifiedFileEntry': 'These entries are not covered by the checksum table and cannot be verified, so each was blocked (the rest import normally): {entries}',
574
579
  'import.undeclaredSectionContent': 'Backup contains content for undeclared sections (not marked as included; these entries will not be imported): {sections}; sample entries: {entries}',
575
580
  'import.secretsPayloadEmpty': 'The backup declares embedded secret values, but no credential ref could be read out of it: this is not "no credentials", it means they were not read (check the backup password and the source machine credential-store layout)',
576
581
  'import.migrated': 'Backup schema migrated from v{from} to v{to}',
@@ -855,6 +860,7 @@ export const en: Record<keyof typeof zh, string> = {
855
860
  'adapter.workspaceSessionsOutsideBundle': 'A further {count} session(s) are not part of this backup (their data was not imported) and stay unregistered; sessions that already exist on this machine are unaffected',
856
861
  'adapter.workspaceSessionsManual': 'The host workspace service cannot attach sessions; {count} session(s) are unregistered: re-import from Settings once DSH is up, or use the offline tool: sessions repair',
857
862
  'adapter.workspaceSessionsOwnerMissing': 'No matching target workspace record, {count} session(s) unregistered: make sure the backup contains that workspace (exporting sessions now carries their workspaces automatically)',
863
+ 'adapter.selfImportBlocked': 'Package contains {path} (sync target config): write refused, local sync target unchanged',
858
864
  'adapter.providerMissing': 'Import data is missing provider {route}',
859
865
  'adapter.namespaceMissing': 'Import data is missing namespace {ref}',
860
866
 
@@ -905,6 +911,7 @@ export const en: Record<keyof typeof zh, string> = {
905
911
  'sync.changesSummary': 'compatibility={compatibility}, {count} change(s)',
906
912
  'sync.configWhitespace': 'Repository URL must not contain whitespace',
907
913
  'sync.configUnparseable': 'Cannot parse repository URL: {url}',
914
+ 'sync.configPrivateHost': 'Repository URL points at this machine or a private network, so it is not accepted as a sync target: {url}',
908
915
  'sync.configUserinfo': 'Do not include a username/password in the repository URL (use the credential field for the auth token; it is never embedded in the URL)',
909
916
  'sync.state.notObject': 'sync-state.json is corrupt: must be an object',
910
917
  'sync.state.schemaUnsupported': 'sync-state.json schemaVersion not supported: {version} (expected {expected})',
@@ -961,6 +968,7 @@ export const en: Record<keyof typeof zh, string> = {
961
968
  'sync.webdav.snapshotInvalid': 'Snapshot {id} failed to parse or is structurally invalid: {err}',
962
969
  'sync.pruneListFailed': 'Remote snapshot pruning failed: could not list remote snapshots, {reason}',
963
970
  'sync.pruneDeleteFailed': 'Remote snapshot pruning failed: deleting snapshot {id} failed, {reason}',
971
+ 'sync.gcSkipped': 'Skipped reclaiming remote orphan files this round ({code}); snapshots are unaffected',
964
972
 
965
973
  // ---------- config marketplace (m-market: read-only public repo directory read + validation) ----------
966
974
  'market.invalidItemId': 'Invalid marketplace item id: {id} (must start with an alphanumeric; only . _ - allowed)',
@@ -398,3 +398,36 @@ test('registerModelTools:tools 服务缺失 → 静默跳过(不抛错、不
398
398
  await fs.rm(tmp, { recursive: true, force: true })
399
399
  }
400
400
  })
401
+
402
+ /* ---------------- SEC-03 读侧可见性:被拒 repoUrl 的成因必须传到模型工具错误里 ---------------- */
403
+
404
+ /**
405
+ * 审计 SEC-01/02/03:`resolveEngine` 读到的 git 通道若因白名单被丢弃(repoUrl 是内网/环回/
406
+ * 带 userinfo/以 '-' 开头),修复前只抛「该通道未配置」——把「你配过但被拒」说成「你没配」,
407
+ * Agent 于是让用户「去配一下」,用户配了还是被拒,彻底卡死。
408
+ * 这里钉住:错误文本必须带上被拒地址与成因,且**绝不构造同步引擎**。
409
+ */
410
+ test('SEC-03 config_sync_push:已存 repoUrl 被白名单拒绝 → 错误带真实成因(不是「未配置」)', async () => {
411
+ const { deps, tmp, transport } = await makeDeps()
412
+ try {
413
+ await fs.mkdir(deps.syncDir, { recursive: true })
414
+ await fs.writeFile(
415
+ path.join(deps.syncDir, 'sync-config.json'),
416
+ JSON.stringify({ schemaVersion: 2, transport: 'git', git: { repoUrl: 'https://192.168.1.10/owner/repo.git' } }),
417
+ 'utf8',
418
+ )
419
+ const tools = createModelTools(deps)
420
+ await assert.rejects(
421
+ () => tools.syncPush({ channel: 'git' }),
422
+ (error: unknown) => {
423
+ const message = error instanceof Error ? error.message : String(error)
424
+ assert.ok(message.includes('192.168.1.10'), '错误必须带上被拒地址: ' + message)
425
+ assert.ok(/repo-url-invalid/.test(message), '错误必须带上机读成因码: ' + message)
426
+ return true
427
+ },
428
+ )
429
+ assert.ok(!transport.calls.includes('upload'), '被拒地址绝不发起任何远端写入')
430
+ } finally {
431
+ await fs.rm(tmp, { recursive: true, force: true })
432
+ }
433
+ })
@@ -37,7 +37,7 @@ import { createEncryptionProvider } from '../security/encryption.ts'
37
37
  import { SECTION_IDS } from '../schema/config.ts'
38
38
  import type { SectionId } from '../schema/types.ts'
39
39
  import { parseSyncChannel, readFullSyncConfig, readSyncConfigFor, SYNC_CHANNELS } from '../sync/sync-config.ts'
40
- import type { SyncConfig, SyncTransportType } from '../sync/sync-config.ts'
40
+ import type { SyncConfig, SyncConfigIssue, SyncTransportType } from '../sync/sync-config.ts'
41
41
  import type { ConfigAdapter, HostContext, SecretScanner } from './types.ts'
42
42
  import { runWithMutationLock } from '../utils/env-lock.ts'
43
43
 
@@ -92,17 +92,33 @@ function filterSectionIds(ids: readonly string[]): SectionId[] {
92
92
  return ids.filter((id): id is SectionId => (SECTION_IDS as readonly string[]).includes(id))
93
93
  }
94
94
 
95
+ /**
96
+ * SEC-03:把「读侧被白名单丢弃的配置」翻成一句可执行的补充说明。
97
+ * 只挑本通道的 issue —— git 地址被拒不该出现在 webdav 的错误里。
98
+ */
99
+ function describeConfigIssues(issues: readonly SyncConfigIssue[], channel: SyncTransportType): string {
100
+ const own = issues.filter((issue) => issue.channel === channel)
101
+ if (own.length === 0) return ''
102
+ return own.map((issue) => `config issue ${issue.code} (${issue.channel}): ${issue.message}`).join('; ')
103
+ }
104
+
95
105
  /** 解析同步引擎:channel 缺省取已配置的活动通道(git/webdav),未配置则抛可操作错误。 */
96
106
  async function resolveEngine(
97
107
  deps: ModelToolsDeps,
98
108
  channel?: SyncTransportType,
99
109
  ): Promise<{ engine: SyncEngine; channel: SyncTransportType }> {
110
+ // SEC-03:两处读取都收集 issues —— 否则「地址被安全校验拒掉」会被报成
111
+ // 「sync-config.json 缺失或损坏」,让模型/用户反复重填同一个被拒地址。
112
+ const issues: SyncConfigIssue[] = []
100
113
  // 未显式指定通道 → 取已配置的活动通道(唯一判定口径 parseSyncChannel;缺省 git)
101
- const full = channel === undefined ? await readFullSyncConfig(deps.syncDir) : null
114
+ const full = channel === undefined ? await readFullSyncConfig(deps.syncDir, issues) : null
102
115
  const ch: SyncTransportType = channel ?? parseSyncChannel(full?.transport) ?? 'git'
103
- const cfg = await readSyncConfigFor(deps.syncDir, ch)
116
+ const cfg = await readSyncConfigFor(deps.syncDir, ch, issues)
104
117
  if (cfg === null) {
105
- throw new Error(engineMsg('core.modelTools.syncChannelNotConfigured', { channel: ch }))
118
+ const base = engineMsg('core.modelTools.syncChannelNotConfigured', { channel: ch })
119
+ const detail = describeConfigIssues(issues, ch)
120
+ // 无 issue = 真的没配(保持原文案);有 issue = 说清成因,绝不吞掉
121
+ throw new Error(detail === '' ? base : `${base} — ${detail}`)
106
122
  }
107
123
  return { engine: deps.makeSyncEngine(cfg), channel: ch }
108
124
  }