dsh-config-manager 0.1.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 (253) hide show
  1. package/README.md +216 -0
  2. package/README.zh-CN.md +218 -0
  3. package/cordis.patch.yml +45 -0
  4. package/dsh.bundle.patch +21 -0
  5. package/dsh.client +39 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/agent-presets.js.map +1 -0
  9. package/lib/adapters/credentials.d.ts +36 -0
  10. package/lib/adapters/credentials.js +121 -0
  11. package/lib/adapters/credentials.js.map +1 -0
  12. package/lib/adapters/file-collection.d.ts +14 -0
  13. package/lib/adapters/file-collection.js +102 -0
  14. package/lib/adapters/file-collection.js.map +1 -0
  15. package/lib/adapters/index.d.ts +38 -0
  16. package/lib/adapters/index.js +46 -0
  17. package/lib/adapters/index.js.map +1 -0
  18. package/lib/adapters/mcp.d.ts +27 -0
  19. package/lib/adapters/mcp.js +175 -0
  20. package/lib/adapters/mcp.js.map +1 -0
  21. package/lib/adapters/plugin-files.d.ts +15 -0
  22. package/lib/adapters/plugin-files.js +91 -0
  23. package/lib/adapters/plugin-files.js.map +1 -0
  24. package/lib/adapters/plugins.d.ts +13 -0
  25. package/lib/adapters/plugins.js +177 -0
  26. package/lib/adapters/plugins.js.map +1 -0
  27. package/lib/adapters/prompts.d.ts +29 -0
  28. package/lib/adapters/prompts.js +228 -0
  29. package/lib/adapters/prompts.js.map +1 -0
  30. package/lib/adapters/providers.d.ts +33 -0
  31. package/lib/adapters/providers.js +143 -0
  32. package/lib/adapters/providers.js.map +1 -0
  33. package/lib/adapters/sessions.d.ts +14 -0
  34. package/lib/adapters/sessions.js +15 -0
  35. package/lib/adapters/sessions.js.map +1 -0
  36. package/lib/adapters/settings.d.ts +23 -0
  37. package/lib/adapters/settings.js +142 -0
  38. package/lib/adapters/settings.js.map +1 -0
  39. package/lib/adapters/skills.d.ts +13 -0
  40. package/lib/adapters/skills.js +14 -0
  41. package/lib/adapters/skills.js.map +1 -0
  42. package/lib/adapters/test-helpers.d.ts +99 -0
  43. package/lib/adapters/test-helpers.js +192 -0
  44. package/lib/adapters/test-helpers.js.map +1 -0
  45. package/lib/adapters/ui.d.ts +26 -0
  46. package/lib/adapters/ui.js +88 -0
  47. package/lib/adapters/ui.js.map +1 -0
  48. package/lib/adapters/workspaces.d.ts +12 -0
  49. package/lib/adapters/workspaces.js +92 -0
  50. package/lib/adapters/workspaces.js.map +1 -0
  51. package/lib/client.d.ts +377 -0
  52. package/lib/client.js +2598 -0
  53. package/lib/client.js.map +1 -0
  54. package/lib/core/analyzer.d.ts +35 -0
  55. package/lib/core/analyzer.js +563 -0
  56. package/lib/core/analyzer.js.map +1 -0
  57. package/lib/core/backup.d.ts +29 -0
  58. package/lib/core/backup.js +184 -0
  59. package/lib/core/backup.js.map +1 -0
  60. package/lib/core/exporter.d.ts +37 -0
  61. package/lib/core/exporter.js +222 -0
  62. package/lib/core/exporter.js.map +1 -0
  63. package/lib/core/importer.d.ts +42 -0
  64. package/lib/core/importer.js +25 -0
  65. package/lib/core/importer.js.map +1 -0
  66. package/lib/core/index.d.ts +12 -0
  67. package/lib/core/index.js +11 -0
  68. package/lib/core/index.js.map +1 -0
  69. package/lib/core/rollback.d.ts +9 -0
  70. package/lib/core/rollback.js +136 -0
  71. package/lib/core/rollback.js.map +1 -0
  72. package/lib/core/types.d.ts +331 -0
  73. package/lib/core/types.js +18 -0
  74. package/lib/core/types.js.map +1 -0
  75. package/lib/core/validator.d.ts +18 -0
  76. package/lib/core/validator.js +77 -0
  77. package/lib/core/validator.js.map +1 -0
  78. package/lib/index.d.ts +51 -0
  79. package/lib/index.js +875 -0
  80. package/lib/index.js.map +1 -0
  81. package/lib/migrations/index.d.ts +29 -0
  82. package/lib/migrations/index.js +56 -0
  83. package/lib/migrations/index.js.map +1 -0
  84. package/lib/migrations/v1-to-v2.d.ts +9 -0
  85. package/lib/migrations/v1-to-v2.js +10 -0
  86. package/lib/migrations/v1-to-v2.js.map +1 -0
  87. package/lib/profiles/index.d.ts +4 -0
  88. package/lib/profiles/index.js +5 -0
  89. package/lib/profiles/index.js.map +1 -0
  90. package/lib/profiles/profile-manager.d.ts +102 -0
  91. package/lib/profiles/profile-manager.js +522 -0
  92. package/lib/profiles/profile-manager.js.map +1 -0
  93. package/lib/schema/config.d.ts +28 -0
  94. package/lib/schema/config.js +136 -0
  95. package/lib/schema/config.js.map +1 -0
  96. package/lib/schema/manifest.d.ts +28 -0
  97. package/lib/schema/manifest.js +109 -0
  98. package/lib/schema/manifest.js.map +1 -0
  99. package/lib/schema/types.d.ts +169 -0
  100. package/lib/schema/types.js +8 -0
  101. package/lib/schema/types.js.map +1 -0
  102. package/lib/schema/versions.d.ts +27 -0
  103. package/lib/schema/versions.js +50 -0
  104. package/lib/schema/versions.js.map +1 -0
  105. package/lib/security/encryption.d.ts +41 -0
  106. package/lib/security/encryption.js +152 -0
  107. package/lib/security/encryption.js.map +1 -0
  108. package/lib/security/index.d.ts +16 -0
  109. package/lib/security/index.js +17 -0
  110. package/lib/security/index.js.map +1 -0
  111. package/lib/security/integrity.d.ts +30 -0
  112. package/lib/security/integrity.js +78 -0
  113. package/lib/security/integrity.js.map +1 -0
  114. package/lib/security/redaction.d.ts +26 -0
  115. package/lib/security/redaction.js +95 -0
  116. package/lib/security/redaction.js.map +1 -0
  117. package/lib/security/secret-scanner.d.ts +48 -0
  118. package/lib/security/secret-scanner.js +253 -0
  119. package/lib/security/secret-scanner.js.map +1 -0
  120. package/lib/security/zip-security.d.ts +27 -0
  121. package/lib/security/zip-security.js +154 -0
  122. package/lib/security/zip-security.js.map +1 -0
  123. package/lib/ui/conflict-view.d.ts +28 -0
  124. package/lib/ui/conflict-view.js +49 -0
  125. package/lib/ui/conflict-view.js.map +1 -0
  126. package/lib/ui/errors.d.ts +17 -0
  127. package/lib/ui/errors.js +76 -0
  128. package/lib/ui/errors.js.map +1 -0
  129. package/lib/ui/export-flow.d.ts +62 -0
  130. package/lib/ui/export-flow.js +79 -0
  131. package/lib/ui/export-flow.js.map +1 -0
  132. package/lib/ui/import-wizard.d.ts +67 -0
  133. package/lib/ui/import-wizard.js +169 -0
  134. package/lib/ui/import-wizard.js.map +1 -0
  135. package/lib/ui/path-mapping.d.ts +38 -0
  136. package/lib/ui/path-mapping.js +75 -0
  137. package/lib/ui/path-mapping.js.map +1 -0
  138. package/lib/ui/progress.d.ts +27 -0
  139. package/lib/ui/progress.js +76 -0
  140. package/lib/ui/progress.js.map +1 -0
  141. package/lib/ui/report.d.ts +21 -0
  142. package/lib/ui/report.js +146 -0
  143. package/lib/ui/report.js.map +1 -0
  144. package/lib/ui/test-helpers.d.ts +62 -0
  145. package/lib/ui/test-helpers.js +147 -0
  146. package/lib/ui/test-helpers.js.map +1 -0
  147. package/lib/ui/types.d.ts +129 -0
  148. package/lib/ui/types.js +21 -0
  149. package/lib/ui/types.js.map +1 -0
  150. package/lib/utils/hashing.d.ts +17 -0
  151. package/lib/utils/hashing.js +41 -0
  152. package/lib/utils/hashing.js.map +1 -0
  153. package/lib/utils/json.d.ts +24 -0
  154. package/lib/utils/json.js +76 -0
  155. package/lib/utils/json.js.map +1 -0
  156. package/lib/utils/logger.d.ts +39 -0
  157. package/lib/utils/logger.js +82 -0
  158. package/lib/utils/logger.js.map +1 -0
  159. package/lib/utils/paths.d.ts +31 -0
  160. package/lib/utils/paths.js +132 -0
  161. package/lib/utils/paths.js.map +1 -0
  162. package/lib/utils/zip.d.ts +54 -0
  163. package/lib/utils/zip.js +285 -0
  164. package/lib/utils/zip.js.map +1 -0
  165. package/package.json +109 -0
  166. package/src/adapters/agent-presets.ts +14 -0
  167. package/src/adapters/credentials.test.ts +90 -0
  168. package/src/adapters/credentials.ts +141 -0
  169. package/src/adapters/file-collection.ts +110 -0
  170. package/src/adapters/files.test.ts +113 -0
  171. package/src/adapters/index.ts +69 -0
  172. package/src/adapters/mcp.test.ts +96 -0
  173. package/src/adapters/mcp.ts +181 -0
  174. package/src/adapters/plugin-files.ts +96 -0
  175. package/src/adapters/plugins.test.ts +72 -0
  176. package/src/adapters/plugins.ts +176 -0
  177. package/src/adapters/prompts.test.ts +110 -0
  178. package/src/adapters/prompts.ts +226 -0
  179. package/src/adapters/providers.test.ts +125 -0
  180. package/src/adapters/providers.ts +161 -0
  181. package/src/adapters/roundtrip.test.ts +188 -0
  182. package/src/adapters/sessions.ts +15 -0
  183. package/src/adapters/settings.test.ts +93 -0
  184. package/src/adapters/settings.ts +165 -0
  185. package/src/adapters/skills.ts +14 -0
  186. package/src/adapters/test-helpers.ts +207 -0
  187. package/src/adapters/ui.test.ts +56 -0
  188. package/src/adapters/ui.ts +106 -0
  189. package/src/adapters/workspaces.test.ts +79 -0
  190. package/src/adapters/workspaces.ts +95 -0
  191. package/src/client/CLIENT_DEPENDENCIES.md +101 -0
  192. package/src/client/ConfigManagerSection.tsx +60 -0
  193. package/src/client/api.ts +270 -0
  194. package/src/client/client-types.ts +27 -0
  195. package/src/client/common/ErrorBanner.tsx +68 -0
  196. package/src/client/common/ProgressBar.tsx +49 -0
  197. package/src/client/common/ReportView.tsx +117 -0
  198. package/src/client/common/ui.tsx +165 -0
  199. package/src/client/config-manager.module.css +759 -0
  200. package/src/client/css-modules.d.ts +8 -0
  201. package/src/client/export/ExportView.tsx +222 -0
  202. package/src/client/import/ConflictList.tsx +72 -0
  203. package/src/client/import/ImportWizardView.tsx +406 -0
  204. package/src/client/import/PathMappingForm.tsx +77 -0
  205. package/src/client/index.ts +65 -0
  206. package/src/client/locales.ts +182 -0
  207. package/src/core/analyzer.ts +631 -0
  208. package/src/core/backup.ts +212 -0
  209. package/src/core/exporter.ts +246 -0
  210. package/src/core/importer.ts +66 -0
  211. package/src/core/index.ts +39 -0
  212. package/src/core/rollback.ts +140 -0
  213. package/src/core/smoke.test.ts +717 -0
  214. package/src/core/types.ts +343 -0
  215. package/src/core/validator.ts +84 -0
  216. package/src/index.ts +942 -0
  217. package/src/migrations/index.ts +71 -0
  218. package/src/migrations/v1-to-v2.ts +17 -0
  219. package/src/profiles/index.ts +8 -0
  220. package/src/profiles/profile-manager.test.ts +280 -0
  221. package/src/profiles/profile-manager.ts +606 -0
  222. package/src/schema/config.ts +137 -0
  223. package/src/schema/manifest.ts +124 -0
  224. package/src/schema/types.ts +179 -0
  225. package/src/schema/versions.ts +55 -0
  226. package/src/security/encryption.ts +180 -0
  227. package/src/security/index.ts +16 -0
  228. package/src/security/integrity.ts +102 -0
  229. package/src/security/redaction.ts +101 -0
  230. package/src/security/secret-scanner.ts +275 -0
  231. package/src/security/security.test.ts +796 -0
  232. package/src/security/zip-security.ts +180 -0
  233. package/src/ui/conflict-view.test.ts +62 -0
  234. package/src/ui/conflict-view.ts +65 -0
  235. package/src/ui/errors.test.ts +44 -0
  236. package/src/ui/errors.ts +95 -0
  237. package/src/ui/export-flow.test.ts +75 -0
  238. package/src/ui/export-flow.ts +128 -0
  239. package/src/ui/import-wizard.test.ts +128 -0
  240. package/src/ui/import-wizard.ts +208 -0
  241. package/src/ui/path-mapping.test.ts +67 -0
  242. package/src/ui/path-mapping.ts +103 -0
  243. package/src/ui/progress.test.ts +42 -0
  244. package/src/ui/progress.ts +90 -0
  245. package/src/ui/report.test.ts +93 -0
  246. package/src/ui/report.ts +140 -0
  247. package/src/ui/test-helpers.ts +177 -0
  248. package/src/ui/types.ts +193 -0
  249. package/src/utils/hashing.ts +53 -0
  250. package/src/utils/json.ts +77 -0
  251. package/src/utils/logger.ts +114 -0
  252. package/src/utils/paths.ts +122 -0
  253. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,796 @@
1
+ /**
2
+ * 安全模块测试(m4-security 验收):
3
+ * encryption(往返/错密/篡改/格式)、zip-security(Zip Slip/symlink/重复名/限额/清理)、
4
+ * integrity(checksum 生成/校验/危险键)、secret-scanner(字段捕获/引用豁免/值形状/深度/循环)、
5
+ * redaction(两模式/幂等/JSON 合法)、core 注入点集成(Exporter scanner+encryption / Importer parseZipOverride)。
6
+ */
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import { Exporter } from '../core/exporter.ts';
14
+ import { Importer } from '../core/importer.ts';
15
+ import { createLogger, type Logger } from '../utils/logger.ts';
16
+ import { zipToBuffer, parseZip, crc32, ZipSafetyError, type ZipWriteEntry } from '../utils/zip.ts';
17
+ import { normalizePath } from '../utils/paths.ts';
18
+ import { sha256Hex } from '../utils/hashing.ts';
19
+ import type {
20
+ ConfigAdapter, CredentialsFacade, ExportSection, FileSystemFacade, HostContext,
21
+ NamespaceInfo, PatchFileFacade, PluginsFacade, SettingsFacade, SnapshotStore,
22
+ WorkspaceFacade,
23
+ } from '../core/types.ts';
24
+ import type { NamespaceRecord, SettingsSection, WorkspaceRecord } from '../schema/types.ts';
25
+ import type { Snapshot } from '../core/types.ts';
26
+
27
+ import {
28
+ createSecretScanner, scanAndRedact, scanText, isSensitiveFieldName,
29
+ matchSecretValuePattern, REDACTED_PLACEHOLDER, DEFAULT_SECRET_FIELD_NAMES,
30
+ } from './secret-scanner.ts';
31
+ import {
32
+ encryptCredentials, decryptCredentials, createEncryptionProvider,
33
+ SecurityError, SCHEMA_MAGIC, SCHEMA_VERSION, HEADER_LENGTH, SALT_LENGTH, IV_LENGTH,
34
+ validatePasswordStrength, SCRYPT_PARAMS,
35
+ } from './encryption.ts';
36
+ import {
37
+ buildChecksums, parseChecksumsTable, verifyChecksums, verifyChecksumsJson, describeMismatches,
38
+ } from './integrity.ts';
39
+ import {
40
+ parseZipHardened, createHardenedZipParser, safeExtractHardened, isExecutableName,
41
+ } from './zip-security.ts';
42
+ import { redact, redactValue, createRedactor, REDACTED } from './redaction.ts';
43
+
44
+ /* ================= 工具 ================= */
45
+
46
+ /** 手工构造原始 ZIP(任意条目名 / externalAttrs,绕过写侧校验,用于读侧攻击测试) */
47
+ function buildRawZip(entryName: string, data: Uint8Array, externalAttrs = 0): Uint8Array {
48
+ const nameBuf = Buffer.from(entryName, 'utf8');
49
+ const crc = crc32(data);
50
+ const local = Buffer.alloc(30);
51
+ local.writeUInt32LE(0x04034b50, 0);
52
+ local.writeUInt16LE(20, 4);
53
+ local.writeUInt16LE(0x0800, 6);
54
+ local.writeUInt16LE(0, 8); // store
55
+ local.writeUInt32LE(crc, 14);
56
+ local.writeUInt32LE(data.length, 18);
57
+ local.writeUInt32LE(data.length, 22);
58
+ local.writeUInt16LE(nameBuf.length, 26);
59
+ local.writeUInt16LE(0, 28);
60
+ const cen = Buffer.alloc(46);
61
+ cen.writeUInt32LE(0x02014b50, 0);
62
+ cen.writeUInt16LE(20, 4);
63
+ cen.writeUInt16LE(20, 6);
64
+ cen.writeUInt16LE(0x0800, 8);
65
+ cen.writeUInt16LE(0, 10);
66
+ cen.writeUInt32LE(crc, 16);
67
+ cen.writeUInt32LE(data.length, 20);
68
+ cen.writeUInt32LE(data.length, 24);
69
+ cen.writeUInt16LE(nameBuf.length, 28);
70
+ cen.writeUInt32LE(0, 34);
71
+ cen.writeUInt32LE(externalAttrs, 38);
72
+ cen.writeUInt32LE(30 + nameBuf.length, 42);
73
+ const eocd = Buffer.alloc(22);
74
+ eocd.writeUInt32LE(0x06054b50, 0);
75
+ eocd.writeUInt16LE(1, 8);
76
+ eocd.writeUInt16LE(1, 10);
77
+ eocd.writeUInt32LE(46 + nameBuf.length, 12);
78
+ eocd.writeUInt32LE(30 + nameBuf.length + data.length, 16);
79
+ return Buffer.concat([local, nameBuf, data, cen, nameBuf, eocd]);
80
+ }
81
+
82
+ interface RawEntry { name: string; data: Uint8Array; externalAttrs?: number }
83
+ function buildRawZipMany(entries: RawEntry[]): Uint8Array {
84
+ const localBlocks: Buffer[] = [];
85
+ const centralBlocks: Buffer[] = [];
86
+ let offset = 0;
87
+ for (const e of entries) {
88
+ const nameBuf = Buffer.from(e.name, 'utf8');
89
+ const crc = crc32(e.data);
90
+ const local = Buffer.alloc(30);
91
+ local.writeUInt32LE(0x04034b50, 0);
92
+ local.writeUInt16LE(20, 4);
93
+ local.writeUInt16LE(0x0800, 6);
94
+ local.writeUInt16LE(0, 8);
95
+ local.writeUInt32LE(crc, 14);
96
+ local.writeUInt32LE(e.data.length, 18);
97
+ local.writeUInt32LE(e.data.length, 22);
98
+ local.writeUInt16LE(nameBuf.length, 26);
99
+ local.writeUInt16LE(0, 28);
100
+ localBlocks.push(local, nameBuf, Buffer.from(e.data));
101
+ const cen = Buffer.alloc(46);
102
+ cen.writeUInt32LE(0x02014b50, 0);
103
+ cen.writeUInt16LE(20, 4);
104
+ cen.writeUInt16LE(20, 6);
105
+ cen.writeUInt16LE(0x0800, 8);
106
+ cen.writeUInt16LE(0, 10);
107
+ cen.writeUInt32LE(crc, 16);
108
+ cen.writeUInt32LE(e.data.length, 20);
109
+ cen.writeUInt32LE(e.data.length, 24);
110
+ cen.writeUInt16LE(nameBuf.length, 28);
111
+ cen.writeUInt32LE(0, 34);
112
+ cen.writeUInt32LE(e.externalAttrs ?? 0, 38);
113
+ cen.writeUInt32LE(offset, 42);
114
+ centralBlocks.push(cen, nameBuf);
115
+ offset += 30 + nameBuf.length + e.data.length;
116
+ }
117
+ const cd = Buffer.concat(centralBlocks);
118
+ const eocd = Buffer.alloc(22);
119
+ eocd.writeUInt32LE(0x06054b50, 0);
120
+ eocd.writeUInt16LE(entries.length, 8);
121
+ eocd.writeUInt16LE(entries.length, 10);
122
+ eocd.writeUInt32LE(cd.length, 12);
123
+ eocd.writeUInt32LE(offset, 16);
124
+ eocd.writeUInt16LE(0, 20);
125
+ return Buffer.concat([...localBlocks, cd, eocd]);
126
+ }
127
+
128
+ async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
129
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-sec-'));
130
+ try {
131
+ return await fn(dir);
132
+ } finally {
133
+ await fs.rm(dir, { recursive: true, force: true });
134
+ }
135
+ }
136
+
137
+ /* ================= 内存 mock(core 注入点集成测试用,精简自 smoke.test.ts) ================= */
138
+
139
+ class MemFs implements FileSystemFacade {
140
+ files = new Map<string, Uint8Array>();
141
+ private readonly homeDir: string;
142
+ constructor(homeDir: string) {
143
+ this.homeDir = homeDir;
144
+ }
145
+ private key(p: string): string {
146
+ return normalizePath(path.resolve(this.homeDir, p));
147
+ }
148
+ async readFile(relPath: string): Promise<Uint8Array> {
149
+ const v = this.files.get(this.key(relPath));
150
+ if (v === undefined) throw new Error(`ENOENT: ${relPath}`);
151
+ return v;
152
+ }
153
+ async writeFile(relPath: string, data: Uint8Array): Promise<void> {
154
+ this.files.set(this.key(relPath), data);
155
+ }
156
+ async exists(relPath: string): Promise<boolean> {
157
+ return this.files.has(this.key(relPath));
158
+ }
159
+ async copy(from: string, to: string): Promise<void> {
160
+ const v = this.files.get(this.key(from));
161
+ if (v === undefined) throw new Error(`ENOENT: ${from}`);
162
+ this.files.set(this.key(to), v);
163
+ }
164
+ async remove(relPath: string): Promise<void> {
165
+ this.files.delete(this.key(relPath));
166
+ }
167
+ async listRecursive(): Promise<string[]> { return []; }
168
+ async mkdir(): Promise<void> {}
169
+ }
170
+
171
+ class MemSettings implements SettingsFacade {
172
+ ns = new Map<string, NamespaceInfo>();
173
+ async describe(namespace: string): Promise<NamespaceInfo> {
174
+ const r = this.ns.get(namespace);
175
+ if (!r) throw new Error(`namespace not found: ${namespace}`);
176
+ return r;
177
+ }
178
+ async replace(namespace: string, value: unknown): Promise<void> {
179
+ const r = this.ns.get(namespace);
180
+ this.ns.set(namespace, { value, revision: (r?.revision ?? 0) + 1, secrets: r?.secrets ?? [] });
181
+ }
182
+ }
183
+
184
+ class MemCredentials implements CredentialsFacade {
185
+ values = new Map<string, string>();
186
+ async describe(ref: string) {
187
+ const v = this.values.get(ref);
188
+ return { configured: v !== undefined, source: v !== undefined ? 'file' : 'env', writable: true };
189
+ }
190
+ async set(ref: string, value: string) { this.values.set(ref, value); }
191
+ async unset(ref: string) { this.values.delete(ref); }
192
+ }
193
+
194
+ class MemPlugins implements PluginsFacade {
195
+ async listInstalled() { return []; }
196
+ async install(_pkg: string) { return { needsRestart: true }; }
197
+ }
198
+
199
+ class MemWorkspace implements WorkspaceFacade {
200
+ records = new Map<string, WorkspaceRecord>();
201
+ async listRecords() { return [...this.records.values()]; }
202
+ async writeRecord(r: WorkspaceRecord) { this.records.set(r.id, r); }
203
+ }
204
+
205
+ class MemPatch implements PatchFileFacade {
206
+ async readPatchLines() { return []; }
207
+ async applyPatchChanges() {}
208
+ }
209
+
210
+ class MemSnapshotStore implements SnapshotStore {
211
+ snapshots = new Map<string, Snapshot>();
212
+ async save(s: Snapshot) { this.snapshots.set(s.id, s); return s.id; }
213
+ async load(id: string) {
214
+ const s = this.snapshots.get(id);
215
+ if (!s) throw new Error(`snapshot not found: ${id}`);
216
+ return s;
217
+ }
218
+ async readBlob() { return new Uint8Array(); }
219
+ }
220
+
221
+ class MockHostContext implements HostContext {
222
+ platform = 'win32';
223
+ arch = 'x64';
224
+ homeDir: string;
225
+ dshVersion = '0.1.0-rc.6';
226
+ log: Logger;
227
+ settings = new MemSettings();
228
+ credentials = new MemCredentials();
229
+ plugins = new MemPlugins();
230
+ workspace = new MemWorkspace();
231
+ patchFile = new MemPatch();
232
+ fs: MemFs;
233
+ constructor(homeDir: string) {
234
+ this.homeDir = homeDir;
235
+ this.fs = new MemFs(homeDir);
236
+ this.log = createLogger({ level: 'error', sink: () => {} });
237
+ }
238
+ }
239
+
240
+ class MiniSettingsAdapter implements ConfigAdapter<SettingsSection> {
241
+ readonly id = 'settings' as const;
242
+ readonly displayName = 'Settings';
243
+ readonly defaultIncluded = true;
244
+ readonly portability = 'portable' as const;
245
+
246
+ async export(ctx: HostContext): Promise<ExportSection<SettingsSection>> {
247
+ const namespaces: Record<string, NamespaceRecord> = {};
248
+ try {
249
+ const info = await ctx.settings.describe('general');
250
+ namespaces['general'] = { value: info.value, base: info.base, revision: info.revision, applies: info.applies, secrets: info.secrets };
251
+ } catch { /* 不存在则跳过 */ }
252
+ return { sectionId: 'settings', data: { version: 1, namespaces }, counts: { namespaces: Object.keys(namespaces).length }, warnings: [] };
253
+ }
254
+ async analyzeImport() { return []; }
255
+ async applyItem() { return { ok: true }; }
256
+ async validate() { return { valid: true, issues: [] }; }
257
+ }
258
+
259
+ /* ================= encryption ================= */
260
+
261
+ test('encryption: 往返加密解密一致(scrypt + AES-256-GCM)', async () => {
262
+ const plaintext = 'DEEPSEEK_API_KEY: sk-super-secret-123\nGITHUB_TOKEN: ghp_abcdefghijklmnopqrstuvwxyz\n';
263
+ const { blob, info } = await encryptCredentials(plaintext, 'correct horse battery');
264
+ // blob 布局
265
+ assert.equal(Buffer.from(blob.subarray(0, 4)).toString('ascii'), SCHEMA_MAGIC);
266
+ assert.equal(blob[4], SCHEMA_VERSION);
267
+ assert.equal(blob.length, HEADER_LENGTH + Buffer.byteLength(plaintext, 'utf8'));
268
+ assert.equal(info.algorithm, 'aes-256-gcm');
269
+ assert.equal(info.kdf, 'scrypt');
270
+ assert.deepEqual(info.kdfParams, { ...SCRYPT_PARAMS });
271
+ assert.equal(info.version, 1);
272
+ // 解密往返
273
+ const decrypted = await decryptCredentials(blob, info, 'correct horse battery');
274
+ assert.equal(decrypted, plaintext);
275
+ });
276
+
277
+ test('encryption: 错误密码 → BAD_PASSWORD(认证失败,不泄明文)', async () => {
278
+ const { blob, info } = await encryptCredentials('sk-super-secret', 'right-password');
279
+ await assert.rejects(
280
+ () => decryptCredentials(blob, info, 'wrong-password'),
281
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
282
+ );
283
+ });
284
+
285
+ test('encryption: 篡改 manifest 加密参数 → TAMPERED', async () => {
286
+ const { blob, info } = await encryptCredentials('sk-super-secret', 'pw-12345678');
287
+ // 篡改 info.authTag(元数据不一致)
288
+ const tampered = { ...info, authTag: Buffer.from('tampered').toString('base64') };
289
+ await assert.rejects(
290
+ () => decryptCredentials(blob, tampered, 'pw-12345678'),
291
+ (err: unknown) => err instanceof SecurityError && err.code === 'TAMPERED',
292
+ );
293
+ // 篡改 info.salt
294
+ await assert.rejects(
295
+ () => decryptCredentials(blob, { ...info, salt: Buffer.alloc(SALT_LENGTH, 1).toString('base64') }, 'pw-12345678'),
296
+ (err: unknown) => err instanceof SecurityError && err.code === 'TAMPERED',
297
+ );
298
+ // 篡改密文字节 → GCM 认证失败(归为 BAD_PASSWORD:密码错误或密文被改)
299
+ const flipped = Buffer.from(blob);
300
+ flipped[HEADER_LENGTH] = flipped[HEADER_LENGTH]! ^ 0xff;
301
+ await assert.rejects(
302
+ () => decryptCredentials(flipped, info, 'pw-12345678'),
303
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
304
+ );
305
+ });
306
+
307
+ test('encryption: 截断 blob → TAMPERED;坏 magic → UNSUPPORTED_FORMAT', async () => {
308
+ const { blob, info } = await encryptCredentials('secret', 'pw-12345678');
309
+ await assert.rejects(
310
+ () => decryptCredentials(blob.subarray(0, 10), info, 'pw-12345678'),
311
+ (err: unknown) => err instanceof SecurityError && err.code === 'TAMPERED',
312
+ );
313
+ const badMagic = Buffer.from(blob);
314
+ badMagic.write('XXXX', 0, 'ascii');
315
+ await assert.rejects(
316
+ () => decryptCredentials(badMagic, info, 'pw-12345678'),
317
+ (err: unknown) => err instanceof SecurityError && err.code === 'UNSUPPORTED_FORMAT',
318
+ );
319
+ // kdfParams 被篡改成超大 N(DoS 向量)→ 拒绝
320
+ await assert.rejects(
321
+ () => decryptCredentials(blob, { ...info, kdfParams: { N: 2 ** 24, r: 8, p: 1, keyLength: 32 } }, 'pw-12345678'),
322
+ (err: unknown) => err instanceof SecurityError && err.code === 'UNSUPPORTED_FORMAT',
323
+ );
324
+ });
325
+
326
+ test('encryption: 每次导出 salt/iv 随机;密码绝不出现在 info 与 blob 头', async () => {
327
+ const password = 'my-secret-password-123';
328
+ const r1 = await encryptCredentials('value', password);
329
+ const r2 = await encryptCredentials('value', password);
330
+ assert.notEqual(r1.info.salt, r2.info.salt);
331
+ assert.notEqual(r1.info.iv, r2.info.iv);
332
+ const infoJson = JSON.stringify(r1.info);
333
+ const infoKeys = Object.keys(r1.info);
334
+ assert.ok(!infoKeys.some((k) => k.toLowerCase().includes('password')), 'info 不得含 password 字段');
335
+ assert.ok(!infoJson.includes(password), '密码值不得进入 info');
336
+ assert.ok(!Buffer.from(r1.blob.subarray(0, HEADER_LENGTH)).toString('utf8').includes(password));
337
+ });
338
+
339
+ test('encryption: createEncryptionProvider 对齐 core EncryptionProvider 契约', async () => {
340
+ const provider = createEncryptionProvider('provider-pw-123');
341
+ const plaintext = 'REF: value';
342
+ const { blob, info } = await provider.encrypt(plaintext); // encrypt 无密码参数(闭包持有)
343
+ const decrypted = await provider.decrypt(blob, info, 'provider-pw-123');
344
+ assert.equal(decrypted, plaintext);
345
+ // decrypt 用调用方传入的密码(换密码解密支持)
346
+ await assert.rejects(
347
+ () => provider.decrypt(blob, info, 'another-pw-123'),
348
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
349
+ );
350
+ });
351
+
352
+ test('encryption: 密码强度校验', () => {
353
+ assert.equal(validatePasswordStrength('short').ok, false);
354
+ assert.equal(validatePasswordStrength('12345678').ok, true);
355
+ assert.ok(validatePasswordStrength('abcdefghijkL1!').ok);
356
+ });
357
+
358
+ /* ================= zip-security ================= */
359
+
360
+ test('zip-security: parseZipHardened 兼容 core 正常 ZIP', () => {
361
+ const buf = zipToBuffer([
362
+ { name: 'a/b.txt', data: Buffer.from('hello') },
363
+ { name: 'c.json', data: Buffer.from('{"x":1}') },
364
+ ]);
365
+ const archive = parseZipHardened(buf);
366
+ assert.deepEqual(archive.names(), ['a/b.txt', 'c.json']);
367
+ assert.equal(Buffer.from(archive.readEntry('a/b.txt')).toString(), 'hello');
368
+ });
369
+
370
+ test('zip-security: Zip Slip / 绝对路径 / 盘符 / UNC / NUL 拒绝', () => {
371
+ const evil = [
372
+ ['../evil.txt', '目录穿越'],
373
+ ['..\\evil.txt', '反斜杠穿越'],
374
+ ['a/../../evil.txt', '深层穿越'],
375
+ ['/abs/path', '绝对路径'],
376
+ ['C:/evil.txt', '盘符'],
377
+ ['C:\\evil.txt', '盘符反斜杠'],
378
+ ['\\\\server\\share', 'UNC'],
379
+ ['a\0b.txt', 'NUL'],
380
+ ] as const;
381
+ for (const [name, label] of evil) {
382
+ assert.throws(() => parseZipHardened(buildRawZip(name, Buffer.from('x'))), ZipSafetyError, `应拒绝 ${label}: ${name}`);
383
+ }
384
+ });
385
+
386
+ test('zip-security: symlink 条目(external attrs S_IFLNK)拒绝', () => {
387
+ const symlinkAttrs = (0xa1ff << 16) >>> 0; // S_IFLNK | 0777
388
+ assert.throws(
389
+ () => parseZipHardened(buildRawZip('link', Buffer.from('target'), symlinkAttrs)),
390
+ /符号链接/,
391
+ );
392
+ // 普通文件 mode 不误伤
393
+ const normalAttrs = (0x81a4 << 16) >>> 0; // S_IFREG | 0644
394
+ const ok = parseZipHardened(buildRawZip('file.txt', Buffer.from('x'), normalAttrs));
395
+ assert.deepEqual(ok.names(), ['file.txt']);
396
+ });
397
+
398
+ test('zip-security: 重复条目名拒绝', () => {
399
+ const buf = buildRawZipMany([
400
+ { name: 'a.txt', data: Buffer.from('first') },
401
+ { name: 'a.txt', data: Buffer.from('second') },
402
+ ]);
403
+ assert.throws(() => parseZipHardened(buf), /重复/);
404
+ });
405
+
406
+ test('zip-security: 条目数超限 / 压缩体积超限拒绝', () => {
407
+ const two = buildRawZipMany([
408
+ { name: 'a.txt', data: Buffer.from('a') },
409
+ { name: 'b.txt', data: Buffer.from('b') },
410
+ ]);
411
+ assert.throws(() => parseZipHardened(two, { maxEntries: 1 }), /条目数/);
412
+ // 压缩体积上限(store 条目 compSize = 数据长度;2 条各 1B,上限 1 则第二条触发)
413
+ assert.throws(() => parseZipHardened(two, { maxCompressedBytes: 1 }), /压缩数据总量/);
414
+ });
415
+
416
+ test('zip-security: safeExtractHardened 正常解压 + 可执行文件告警', async () => {
417
+ await withTmp(async (dir) => {
418
+ const zipPath = path.join(dir, 'x.zip');
419
+ const destDir = path.join(dir, 'out');
420
+ await fs.writeFile(zipPath, zipToBuffer([
421
+ { name: 'custom/skills/a.md', data: Buffer.from('# skill') },
422
+ { name: 'plugin-files/x.sh', data: Buffer.from('#!/bin/sh') },
423
+ ]));
424
+ const result = await safeExtractHardened(zipPath, destDir);
425
+ assert.deepEqual(result.files.sort(), ['custom/skills/a.md', 'plugin-files/x.sh']);
426
+ assert.ok(result.warnings.some((w) => w.includes('x.sh')), '可执行文件应产生告警');
427
+ const content = await fs.readFile(path.join(destDir, 'custom', 'skills', 'a.md'), 'utf8');
428
+ assert.equal(content, '# skill');
429
+ });
430
+ });
431
+
432
+ test('zip-security: 解压中途失败 → 中止并完整清理目标目录', async () => {
433
+ await withTmp(async (dir) => {
434
+ const zipPath = path.join(dir, 'bomb.zip');
435
+ const destDir = path.join(dir, 'out');
436
+ // 条目 1 正常写入,条目 2 高压缩比(zip bomb 模拟)→ readEntry 触发 ratio 上限
437
+ await fs.writeFile(zipPath, zipToBuffer([
438
+ { name: 'ok.txt', data: Buffer.from('written-first') },
439
+ { name: 'bomb.txt', data: Buffer.from('a'.repeat(50_000)) },
440
+ ]));
441
+ await assert.rejects(
442
+ () => safeExtractHardened(zipPath, destDir, { maxRatio: 5 }),
443
+ ZipSafetyError,
444
+ );
445
+ await assert.rejects(() => fs.access(destDir), /ENOENT/, '失败后目标目录应被完整清理');
446
+ });
447
+ });
448
+
449
+ test('zip-security: createHardenedZipParser 签名对齐 parseZipOverride', () => {
450
+ const parser = createHardenedZipParser({ maxEntries: 100 });
451
+ const archive = parser(zipToBuffer([{ name: 'a.txt', data: Buffer.from('x') }]), { maxEntries: 50 });
452
+ assert.deepEqual(archive.names(), ['a.txt']);
453
+ assert.equal(isExecutableName('run.sh'), true);
454
+ assert.equal(isExecutableName('readme.md'), false);
455
+ });
456
+
457
+ /* ================= integrity ================= */
458
+
459
+ test('integrity: checksums 生成与校验(匹配/不符/缺失)', () => {
460
+ const entries = [
461
+ { name: 'config/settings.json', data: Buffer.from('{"a":1}') },
462
+ { name: 'custom/skills/a.md', data: Buffer.from('# hi') },
463
+ ];
464
+ const table = buildChecksums(entries);
465
+ assert.equal(table['config/settings.json'], sha256Hex(Buffer.from('{"a":1}')));
466
+ assert.equal(Object.keys(table).length, 2);
467
+
468
+ const map = new Map(entries.map((e) => [e.name, e.data]));
469
+ assert.deepEqual(verifyChecksums(map, table), { ok: true, mismatches: [], missing: [] });
470
+ // 内容被改 → mismatches
471
+ const tampered = new Map(map);
472
+ tampered.set('config/settings.json', Buffer.from('{"a":2}'));
473
+ const r1 = verifyChecksums(tampered, table);
474
+ assert.equal(r1.ok, false);
475
+ assert.deepEqual(r1.mismatches, ['config/settings.json']);
476
+ // 缺文件 → missing
477
+ const missing = new Map<string, Uint8Array>();
478
+ missing.set('config/settings.json', map.get('config/settings.json')!);
479
+ const r2 = verifyChecksums(missing, table);
480
+ assert.equal(r2.ok, false);
481
+ assert.deepEqual(r2.missing, ['custom/skills/a.md']);
482
+ assert.ok(describeMismatches(r2).includes('custom/skills/a.md'));
483
+ });
484
+
485
+ test('integrity: parseChecksumsTable 结构校验(hex/危险键/非法路径/非对象)', () => {
486
+ const table = parseChecksumsTable(JSON.stringify({ 'a/b.txt': sha256Hex(Buffer.from('x')) }));
487
+ assert.equal(table['a/b.txt'], sha256Hex(Buffer.from('x')));
488
+ // 非 hex 值
489
+ assert.throws(() => parseChecksumsTable(JSON.stringify({ 'a/b.txt': 'zzz' })), /SHA-256/);
490
+ // 危险键(原型污染;注意对象字面量 '__proto__' 会被当原型设置,须用字符串构造 JSON)
491
+ assert.throws(() => parseChecksumsTable(`{"__proto__": "${sha256Hex(Buffer.from('x'))}"}`), /危险键/);
492
+ assert.throws(() => parseChecksumsTable(`{"constructor": "${sha256Hex(Buffer.from('x'))}"}`), /危险键/);
493
+ // 反斜杠路径(非 ZIP 正斜杠形态)
494
+ assert.throws(() => parseChecksumsTable(JSON.stringify({ 'a\\b.txt': sha256Hex(Buffer.from('x')) })), /非法相对路径/);
495
+ // 非对象 / 值非 hex
496
+ assert.throws(() => parseChecksumsTable('[1,2]'), /必须是 JSON 对象/);
497
+ assert.throws(() => parseChecksumsTable(JSON.stringify({ 'a/b.txt': 'not-a-hash' })), /SHA-256/);
498
+ });
499
+
500
+ test('integrity: verifyChecksumsJson 一步到位(不匹配 → ok:false)', () => {
501
+ const entries = new Map<string, Uint8Array>([['a.txt', Buffer.from('x')]]);
502
+ const raw = JSON.stringify({ 'a.txt': sha256Hex(Buffer.from('y')) });
503
+ const result = verifyChecksumsJson(entries, raw);
504
+ assert.equal(result.ok, false);
505
+ assert.deepEqual(result.mismatches, ['a.txt']);
506
+ });
507
+
508
+ /* ================= secret-scanner ================= */
509
+
510
+ test('scanner: 捕获敏感字段并剥离(多形态/大小写不敏感),不改原对象', () => {
511
+ const original = {
512
+ apiKey: 'sk-abc123',
513
+ API_KEY: 'v1',
514
+ 'api-key': 'v2',
515
+ GithubToken: 'ghp_abcdefghijklmnopqrstuvwxyz',
516
+ client_secret: 'cs1',
517
+ privateKey: 'pk1',
518
+ Authorization: 'Bearer xyz',
519
+ passwd: 'p1',
520
+ theme: 'dark', // 不敏感
521
+ monkey: 'banana', // key 后缀不误伤
522
+ sessionIds: ['s1'], // session 不误伤
523
+ nested: { credential: 'c1', ok: true },
524
+ list: [{ token: 't1' }, { title: 'ok' }],
525
+ };
526
+ const before = structuredClone(original);
527
+ const { sanitized, hits } = scanAndRedact(original);
528
+ // 原对象未被修改
529
+ assert.deepEqual(original, before);
530
+ const s = sanitized as Record<string, unknown>;
531
+ assert.equal(s['apiKey'], REDACTED_PLACEHOLDER);
532
+ assert.equal(s['API_KEY'], REDACTED_PLACEHOLDER);
533
+ assert.equal(s['api-key'], REDACTED_PLACEHOLDER);
534
+ assert.equal(s['GithubToken'], REDACTED_PLACEHOLDER);
535
+ assert.equal(s['client_secret'], REDACTED_PLACEHOLDER);
536
+ assert.equal(s['privateKey'], REDACTED_PLACEHOLDER);
537
+ assert.equal(s['Authorization'], REDACTED_PLACEHOLDER);
538
+ assert.equal(s['passwd'], REDACTED_PLACEHOLDER);
539
+ assert.equal(s['theme'], 'dark');
540
+ assert.equal(s['monkey'], 'banana');
541
+ assert.deepEqual(s['sessionIds'], ['s1']);
542
+ assert.equal((s['nested'] as Record<string, unknown>)['credential'], REDACTED_PLACEHOLDER);
543
+ assert.equal((s['nested'] as Record<string, unknown>)['ok'], true);
544
+ assert.equal(((s['list'] as unknown[])[0] as Record<string, unknown>)['token'], REDACTED_PLACEHOLDER);
545
+ // hits 记录路径
546
+ const paths = hits.map((h) => h.path).sort();
547
+ assert.ok(paths.includes('apiKey'));
548
+ assert.ok(paths.includes('list[0].token'));
549
+ assert.ok(paths.includes('nested.credential'));
550
+ assert.ok(hits.every((h) => !JSON.stringify(h).includes('sk-abc123')), 'hits 不得含值');
551
+ });
552
+
553
+ test('scanner: 引用豁免(apiKeyEnv 等只存名字的字段、env 名形态值)', () => {
554
+ const input = {
555
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
556
+ api_key_env: 'GITHUB_TOKEN',
557
+ apiKey: 'DEEPSEEK_API_KEY', // 值是 env 变量名形态 → 视为引用
558
+ realKey: 'sk-real-secret-value',
559
+ tokenRef: 'MY_TOKEN_REF',
560
+ };
561
+ const { sanitized } = scanAndRedact(input);
562
+ const s = sanitized as Record<string, unknown>;
563
+ assert.equal(s['apiKeyEnv'], 'DEEPSEEK_API_KEY');
564
+ assert.equal(s['api_key_env'], 'GITHUB_TOKEN');
565
+ assert.equal(s['apiKey'], 'DEEPSEEK_API_KEY');
566
+ assert.equal(s['realKey'], REDACTED_PLACEHOLDER);
567
+ assert.equal(s['tokenRef'], 'MY_TOKEN_REF');
568
+ });
569
+
570
+ test('scanner: 值形状启发式(字段名无关的 secret 形状也剥离)', () => {
571
+ const input = {
572
+ note: 'use sk-abc1234567890abc for api',
573
+ jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U',
574
+ aws: 'AKIAIOSFODNN7EXAMPLE',
575
+ github: 'ghp_abcdefghijklmnopqrstuvwxyz',
576
+ pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...',
577
+ fine: 'just a note',
578
+ };
579
+ const { sanitized, hits } = scanAndRedact(input);
580
+ const s = sanitized as Record<string, unknown>;
581
+ assert.equal(s['note'], REDACTED_PLACEHOLDER, 'sk- 形状应剥离');
582
+ assert.equal(s['jwt'], REDACTED_PLACEHOLDER, 'JWT 应剥离');
583
+ assert.equal(s['aws'], REDACTED_PLACEHOLDER, 'AKIA 应剥离');
584
+ assert.equal(s['github'], REDACTED_PLACEHOLDER, 'ghp_ 应剥离');
585
+ assert.equal(s['pem'], REDACTED_PLACEHOLDER, 'PEM 私钥应剥离');
586
+ assert.equal(s['fine'], 'just a note');
587
+ assert.ok(hits.length >= 5);
588
+ assert.equal(matchSecretValuePattern('sk-abc1234567890abc'), 'openai-style-key');
589
+ assert.equal(matchSecretValuePattern('plain text'), null);
590
+ });
591
+
592
+ test('scanner: 深层嵌套上限(≤64 通过,超限抛错)与循环引用防护', () => {
593
+ const deep = (n: number): unknown => {
594
+ let v: unknown = 'leaf';
595
+ for (let i = 0; i < n; i++) v = { x: v };
596
+ return v;
597
+ };
598
+ assert.doesNotThrow(() => scanAndRedact({ a: deep(60) }));
599
+ assert.throws(() => scanAndRedact({ a: deep(70) }), /深度/);
600
+ // 循环引用:不挂死,明确抛错
601
+ const cyclic: Record<string, unknown> = { name: 'x' };
602
+ cyclic['self'] = cyclic;
603
+ assert.throws(() => scanAndRedact(cyclic), /循环引用/);
604
+ });
605
+
606
+ test('scanner: Uint8Array 跳过、数组根、空字符串不报 hit、extra 名单扩展', () => {
607
+ const bin = new Uint8Array([1, 2, 3]);
608
+ const input = { data: bin, empty: '', clientSecret2: 'x', normal: 42 };
609
+ const { sanitized, hits } = scanAndRedact(input, { extraFieldNames: ['clientsecret2'] });
610
+ const s = sanitized as Record<string, unknown>;
611
+ assert.equal(s['data'], bin);
612
+ assert.equal(s['empty'], '');
613
+ assert.equal(s['clientSecret2'], REDACTED_PLACEHOLDER, 'extra 名单生效');
614
+ assert.equal(s['normal'], 42);
615
+ assert.equal(hits.length, 1);
616
+ // 数组根
617
+ const arr = scanAndRedact([{ token: 'x' }]);
618
+ assert.equal(((arr.sanitized as { token: string }[])[0] as { token: string }).token, REDACTED_PLACEHOLDER);
619
+ });
620
+
621
+ test('scanner: isSensitiveFieldName / createSecretScanner(core 契约)', () => {
622
+ assert.equal(isSensitiveFieldName('apiKey'), true);
623
+ assert.equal(isSensitiveFieldName('API_KEY'), true);
624
+ assert.equal(isSensitiveFieldName('github_token'), true);
625
+ assert.equal(isSensitiveFieldName('theme'), false);
626
+ assert.equal(isSensitiveFieldName('sessionIds'), false);
627
+ assert.equal(isSensitiveFieldName('monkey'), false);
628
+ assert.ok(DEFAULT_SECRET_FIELD_NAMES.includes('apikey'));
629
+
630
+ const scanner = createSecretScanner({ valuePatterns: true });
631
+ const r = scanner.scanAndRedact({ token: 'sk-abc1234567890abc' });
632
+ assert.equal((r.sanitized as Record<string, unknown>)['token'], REDACTED_PLACEHOLDER);
633
+ // scanText 可选方法存在(文件类分区文本扫描)
634
+ const textHits = scanner.scanText!('apiKey = sk-abc1234567890abc\nBearer xyz1234567890abcdef\nplain line');
635
+ assert.ok(textHits.length >= 2, `应命中字段形态与值形状,实际 ${textHits.length}`);
636
+ assert.ok(textHits.some((h) => h.path.startsWith('line:')));
637
+ });
638
+
639
+ /* ================= redaction ================= */
640
+
641
+ test('redaction: 结构化字段三形态 + 值形状 + URL query', () => {
642
+ const json = redact('{"apiKey": "sk-abc1234567890abc", "theme": "dark", "client_secret": "cs-value-1"}');
643
+ assert.ok(!json.includes('sk-abc1234567890abc'));
644
+ assert.ok(json.includes('"apiKey": "***REDACTED***"'));
645
+ assert.ok(json.includes('"client_secret": "***REDACTED***"'));
646
+ assert.ok(json.includes('"theme": "dark"'));
647
+ assert.doesNotThrow(() => JSON.parse(json), '输出仍是合法 JSON');
648
+
649
+ const kv = redact('token=abc123def456 theme=dark');
650
+ assert.ok(kv.includes('token=***REDACTED***'));
651
+ assert.ok(kv.includes('theme=dark'));
652
+
653
+ const colon = redact('Authorization: Bearer xyz1234567890abcdef');
654
+ assert.ok(colon.includes('Authorization: ***REDACTED***'));
655
+
656
+ const shape = redact('please use sk-abc1234567890abc now, aws key AKIAIOSFODNN7EXAMPLE');
657
+ assert.ok(!shape.includes('sk-abc1234567890abc'));
658
+ assert.ok(!shape.includes('AKIAIOSFODNN7EXAMPLE'));
659
+
660
+ const url = redact('https://x.com/api?token=secret-token-1&theme=dark&api_key=another-secret');
661
+ assert.ok(!url.includes('secret-token-1'));
662
+ assert.ok(!url.includes('another-secret'));
663
+ assert.ok(url.includes('theme=dark'));
664
+ });
665
+
666
+ test('redaction: 幂等(重复脱敏结果不变)+ createRedactor 与 redact 一致', () => {
667
+ const samples = [
668
+ '{"apiKey": "sk-abc1234567890abc", "theme": "dark"}',
669
+ 'token=abc123def456 theme=dark',
670
+ 'Authorization: Bearer xyz1234567890abcdef',
671
+ 'url?token=secret-token-1&api_key=another-secret',
672
+ 'plain text no secrets',
673
+ ];
674
+ for (const s of samples) {
675
+ const once = redact(s);
676
+ const twice = redact(once);
677
+ assert.equal(twice, once, `应幂等: ${s}`);
678
+ assert.equal(createRedactor()(s), once);
679
+ }
680
+ });
681
+
682
+ test('redaction: redactValue 对象级掩码 + 大小写 + 自定义附加名单', () => {
683
+ const masked = redactValue({ apiKey: 'sk-abc', nested: { clientSecret: 'cs', ok: 1 }, list: [{ token: 't' }] });
684
+ const m = masked as Record<string, unknown>;
685
+ assert.equal(m['apiKey'], REDACTED);
686
+ assert.equal((m['nested'] as Record<string, unknown>)['clientSecret'], REDACTED);
687
+ assert.equal((m['nested'] as Record<string, unknown>)['ok'], 1);
688
+ assert.equal(((m['list'] as unknown[])[0] as Record<string, unknown>)['token'], REDACTED);
689
+
690
+ assert.ok(redact('API_KEY=xyz123').includes('API_KEY=***REDACTED***'));
691
+ assert.ok(redact('monkey=banana').includes('monkey=banana'), '不敏感字段不受影响');
692
+ // 附加名单(规范化名)
693
+ const custom = createRedactor(['customfield']);
694
+ assert.ok(custom('customField=value123').includes('customField=***REDACTED***'));
695
+ });
696
+
697
+ /* ================= core 注入点集成 ================= */
698
+
699
+ test('集成: Exporter + createSecretScanner 剥离自定义敏感字段(第二道防线)', async () => {
700
+ await withTmp(async (dir) => {
701
+ const homeDir = path.join(dir, 'home');
702
+ const ctx = new MockHostContext(homeDir);
703
+ ctx.settings.ns.set('general', {
704
+ value: { theme: 'dark', apiKey: 'sk-super-secret-value', apiKeyEnv: 'DEEPSEEK_API_KEY' },
705
+ revision: 3,
706
+ secrets: [],
707
+ });
708
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
709
+ const zipPath = path.join(dir, 'x.zip');
710
+ await new Exporter({
711
+ ctx,
712
+ adapters,
713
+ scanner: createSecretScanner(), // 注入强化 scanner
714
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
715
+ }).export({ includeSecrets: false, outPath: zipPath });
716
+
717
+ const archive = parseZip(await fs.readFile(zipPath));
718
+ const settingsJson = archive.readEntryText('config/settings.json');
719
+ assert.ok(!settingsJson.includes('sk-super-secret-value'), 'secret 值不得写入导出');
720
+ assert.ok(settingsJson.includes('"apiKey": ""'), 'apiKey 应剥离为空串占位');
721
+ assert.ok(settingsJson.includes('"apiKeyEnv": "DEEPSEEK_API_KEY"'), '引用字段应保留');
722
+ assert.ok(settingsJson.includes('"theme": "dark"'));
723
+ });
724
+ });
725
+
726
+ test('集成: Exporter + EncryptionProvider 加密备份(secrets.enc + manifest + 解密恢复)', async () => {
727
+ await withTmp(async (dir) => {
728
+ const homeDir = path.join(dir, 'home');
729
+ const ctx = new MockHostContext(homeDir);
730
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
731
+ const credentialsYaml = 'DEEPSEEK_API_KEY: sk-super-secret-value\nGITHUB_TOKEN: ghp_abcdefghijklmnopqrstuvwxyz\n';
732
+ await ctx.fs.writeFile(path.join(homeDir, '.credentials.yaml'), Buffer.from(credentialsYaml, 'utf8'));
733
+
734
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
735
+ const zipPath = path.join(dir, 'enc.zip');
736
+ const password = 'backup-password-123';
737
+ const exporter = new Exporter({
738
+ ctx,
739
+ adapters,
740
+ scanner: createSecretScanner(),
741
+ encryption: createEncryptionProvider(password),
742
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
743
+ });
744
+ const { manifest } = await exporter.export({ includeSecrets: true, outPath: zipPath });
745
+
746
+ assert.equal(manifest.security.containsSecrets, true);
747
+ assert.equal(manifest.security.encrypted, true);
748
+ assert.ok(manifest.security.encryption, '应记录加密参数');
749
+ assert.equal(manifest.security.encryption!.kdf, 'scrypt');
750
+
751
+ const archive = parseZip(await fs.readFile(zipPath));
752
+ assert.ok(archive.has('security/secrets.enc'), 'secrets.enc 应写入 ZIP');
753
+ // 密码绝不出现在 manifest 文本
754
+ const manifestText = archive.readEntryText('manifest.json');
755
+ assert.ok(!manifestText.includes(password), '密码不得出现在 manifest');
756
+ assert.ok(!manifestText.includes('sk-super-secret-value'), '明文秘密不得出现在 manifest');
757
+
758
+ // 解密恢复
759
+ const blob = archive.readEntry('security/secrets.enc');
760
+ const decrypted = await decryptCredentials(blob, manifest.security.encryption!, password);
761
+ assert.equal(decrypted, credentialsYaml);
762
+ });
763
+ });
764
+
765
+ test('集成: includeSecrets 无加密提供者 → 拒绝(绝不明文导出秘密)', async () => {
766
+ await withTmp(async (dir) => {
767
+ const ctx = new MockHostContext(path.join(dir, 'home'));
768
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
769
+ const exporter = new Exporter({ ctx, adapters: [new MiniSettingsAdapter()], now: () => new Date() });
770
+ await assert.rejects(
771
+ () => exporter.export({ includeSecrets: true, outPath: path.join(dir, 'x.zip') }),
772
+ /EncryptionProvider/,
773
+ );
774
+ });
775
+ });
776
+
777
+ test('集成: Importer + parseZipOverride=createHardenedZipParser 正常解析备份', async () => {
778
+ await withTmp(async (dir) => {
779
+ const homeDir = path.join(dir, 'home');
780
+ const ctx = new MockHostContext(homeDir);
781
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
782
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
783
+ const zipPath = path.join(dir, 'x.zip');
784
+ await new Exporter({ ctx, adapters, now: () => new Date() }).export({ includeSecrets: false, outPath: zipPath });
785
+
786
+ const importer = new Importer({
787
+ ctx,
788
+ adapters,
789
+ snapshotStore: new MemSnapshotStore(),
790
+ parseZipOverride: createHardenedZipParser(), // 注入强化 ZIP 安全解析
791
+ });
792
+ const analysis = await importer.analyzeImport(zipPath);
793
+ assert.equal(analysis.valid, true);
794
+ assert.deepEqual(analysis.sectionsInZip, ['settings']);
795
+ });
796
+ });