@logictan/dsh-config-manager 0.1.60

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 (429) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/cordis.patch.yml +21 -0
  4. package/lib/adapters/agent-instructions.d.ts +13 -0
  5. package/lib/adapters/agent-instructions.js +46 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/credentials.d.ts +37 -0
  9. package/lib/adapters/credentials.js +122 -0
  10. package/lib/adapters/file-collection.d.ts +15 -0
  11. package/lib/adapters/file-collection.js +127 -0
  12. package/lib/adapters/index.d.ts +57 -0
  13. package/lib/adapters/index.js +55 -0
  14. package/lib/adapters/link-report.d.ts +16 -0
  15. package/lib/adapters/link-report.js +47 -0
  16. package/lib/adapters/mcp.d.ts +28 -0
  17. package/lib/adapters/mcp.js +178 -0
  18. package/lib/adapters/plugin-files.d.ts +18 -0
  19. package/lib/adapters/plugin-files.js +142 -0
  20. package/lib/adapters/plugins.d.ts +41 -0
  21. package/lib/adapters/plugins.js +603 -0
  22. package/lib/adapters/pnpm-workspace.d.ts +26 -0
  23. package/lib/adapters/pnpm-workspace.js +147 -0
  24. package/lib/adapters/prompts.d.ts +30 -0
  25. package/lib/adapters/prompts.js +231 -0
  26. package/lib/adapters/providers.d.ts +34 -0
  27. package/lib/adapters/providers.js +155 -0
  28. package/lib/adapters/self.d.ts +17 -0
  29. package/lib/adapters/self.js +71 -0
  30. package/lib/adapters/sessions.d.ts +14 -0
  31. package/lib/adapters/sessions.js +15 -0
  32. package/lib/adapters/settings.d.ts +28 -0
  33. package/lib/adapters/settings.js +165 -0
  34. package/lib/adapters/skills.d.ts +13 -0
  35. package/lib/adapters/skills.js +14 -0
  36. package/lib/adapters/test-helpers.d.ts +125 -0
  37. package/lib/adapters/test-helpers.js +236 -0
  38. package/lib/adapters/ui.d.ts +27 -0
  39. package/lib/adapters/ui.js +89 -0
  40. package/lib/adapters/workspaces.d.ts +13 -0
  41. package/lib/adapters/workspaces.js +106 -0
  42. package/lib/client.d.ts +1026 -0
  43. package/lib/client.js +10047 -0
  44. package/lib/core/analyzer.d.ts +99 -0
  45. package/lib/core/analyzer.js +934 -0
  46. package/lib/core/backup.d.ts +138 -0
  47. package/lib/core/backup.js +513 -0
  48. package/lib/core/boot-rescue.d.ts +146 -0
  49. package/lib/core/boot-rescue.js +618 -0
  50. package/lib/core/cache-cleaner.d.ts +31 -0
  51. package/lib/core/cache-cleaner.js +95 -0
  52. package/lib/core/config-lifecycle.d.ts +180 -0
  53. package/lib/core/config-lifecycle.js +481 -0
  54. package/lib/core/config-snapshot.d.ts +135 -0
  55. package/lib/core/config-snapshot.js +424 -0
  56. package/lib/core/config-state.d.ts +70 -0
  57. package/lib/core/config-state.js +177 -0
  58. package/lib/core/consult-source.d.ts +58 -0
  59. package/lib/core/consult-source.js +260 -0
  60. package/lib/core/crash-report.d.ts +84 -0
  61. package/lib/core/crash-report.js +317 -0
  62. package/lib/core/exporter.d.ts +68 -0
  63. package/lib/core/exporter.js +375 -0
  64. package/lib/core/ghost-sweep.d.ts +19 -0
  65. package/lib/core/ghost-sweep.js +52 -0
  66. package/lib/core/importer.d.ts +60 -0
  67. package/lib/core/importer.js +29 -0
  68. package/lib/core/index.d.ts +14 -0
  69. package/lib/core/index.js +14 -0
  70. package/lib/core/journal.d.ts +197 -0
  71. package/lib/core/journal.js +418 -0
  72. package/lib/core/local-plugin-host.d.ts +35 -0
  73. package/lib/core/local-plugin-host.js +120 -0
  74. package/lib/core/local-plugin-pack.d.ts +135 -0
  75. package/lib/core/local-plugin-pack.js +278 -0
  76. package/lib/core/messages.d.ts +310 -0
  77. package/lib/core/messages.js +621 -0
  78. package/lib/core/migration-consult.d.ts +117 -0
  79. package/lib/core/migration-consult.js +363 -0
  80. package/lib/core/migration-history.d.ts +153 -0
  81. package/lib/core/migration-history.js +411 -0
  82. package/lib/core/model-tools.d.ts +50 -0
  83. package/lib/core/model-tools.js +148 -0
  84. package/lib/core/msg-types.d.ts +8 -0
  85. package/lib/core/msg-types.js +6 -0
  86. package/lib/core/phase3-child-crash.d.ts +1 -0
  87. package/lib/core/phase3-child-crash.js +87 -0
  88. package/lib/core/phase3-host.d.ts +138 -0
  89. package/lib/core/phase3-host.js +359 -0
  90. package/lib/core/phase3-prod-child.d.ts +1 -0
  91. package/lib/core/phase3-prod-child.js +35 -0
  92. package/lib/core/phase4-crash-child.d.ts +1 -0
  93. package/lib/core/phase4-crash-child.js +103 -0
  94. package/lib/core/plugin-cli.d.ts +150 -0
  95. package/lib/core/plugin-cli.js +497 -0
  96. package/lib/core/reconcile.d.ts +120 -0
  97. package/lib/core/reconcile.js +389 -0
  98. package/lib/core/recovery-orchestrator.d.ts +94 -0
  99. package/lib/core/recovery-orchestrator.js +327 -0
  100. package/lib/core/restore.d.ts +162 -0
  101. package/lib/core/restore.js +703 -0
  102. package/lib/core/rollback.d.ts +12 -0
  103. package/lib/core/rollback.js +145 -0
  104. package/lib/core/run-registry.d.ts +85 -0
  105. package/lib/core/run-registry.js +157 -0
  106. package/lib/core/startup-barrier.d.ts +53 -0
  107. package/lib/core/startup-barrier.js +77 -0
  108. package/lib/core/types.d.ts +515 -0
  109. package/lib/core/types.js +27 -0
  110. package/lib/core/undo.d.ts +73 -0
  111. package/lib/core/undo.js +87 -0
  112. package/lib/core/validator.d.ts +18 -0
  113. package/lib/core/validator.js +77 -0
  114. package/lib/core/verify-recovery.d.ts +21 -0
  115. package/lib/core/verify-recovery.js +256 -0
  116. package/lib/core/watcher.d.ts +101 -0
  117. package/lib/core/watcher.js +219 -0
  118. package/lib/index.d.ts +238 -0
  119. package/lib/index.js +2764 -0
  120. package/lib/migrations/index.d.ts +29 -0
  121. package/lib/migrations/index.js +56 -0
  122. package/lib/migrations/v1-to-v2.d.ts +9 -0
  123. package/lib/migrations/v1-to-v2.js +10 -0
  124. package/lib/profiles/index.d.ts +4 -0
  125. package/lib/profiles/index.js +5 -0
  126. package/lib/profiles/profile-manager.d.ts +106 -0
  127. package/lib/profiles/profile-manager.js +543 -0
  128. package/lib/schema/config.d.ts +28 -0
  129. package/lib/schema/config.js +165 -0
  130. package/lib/schema/index.d.ts +24 -0
  131. package/lib/schema/index.js +26 -0
  132. package/lib/schema/manifest.d.ts +28 -0
  133. package/lib/schema/manifest.js +109 -0
  134. package/lib/schema/tombstones.d.ts +46 -0
  135. package/lib/schema/tombstones.js +64 -0
  136. package/lib/schema/types.d.ts +232 -0
  137. package/lib/schema/types.js +8 -0
  138. package/lib/schema/versions.d.ts +27 -0
  139. package/lib/schema/versions.js +50 -0
  140. package/lib/security/encryption.d.ts +85 -0
  141. package/lib/security/encryption.js +279 -0
  142. package/lib/security/index.d.ts +16 -0
  143. package/lib/security/index.js +17 -0
  144. package/lib/security/integrity.d.ts +30 -0
  145. package/lib/security/integrity.js +78 -0
  146. package/lib/security/redaction.d.ts +26 -0
  147. package/lib/security/redaction.js +95 -0
  148. package/lib/security/secret-scanner.d.ts +86 -0
  149. package/lib/security/secret-scanner.js +404 -0
  150. package/lib/security/vault.d.ts +59 -0
  151. package/lib/security/vault.js +151 -0
  152. package/lib/security/zip-security.d.ts +27 -0
  153. package/lib/security/zip-security.js +154 -0
  154. package/lib/sync/autosync-config.d.ts +39 -0
  155. package/lib/sync/autosync-config.js +151 -0
  156. package/lib/sync/autosync-scheduler.d.ts +139 -0
  157. package/lib/sync/autosync-scheduler.js +564 -0
  158. package/lib/sync/fs.d.ts +18 -0
  159. package/lib/sync/fs.js +49 -0
  160. package/lib/sync/git/git-transport.d.ts +100 -0
  161. package/lib/sync/git/git-transport.js +437 -0
  162. package/lib/sync/github-auth.d.ts +131 -0
  163. package/lib/sync/github-auth.js +264 -0
  164. package/lib/sync/layout.d.ts +43 -0
  165. package/lib/sync/layout.js +203 -0
  166. package/lib/sync/snapshot-json.d.ts +9 -0
  167. package/lib/sync/snapshot-json.js +107 -0
  168. package/lib/sync/sync-config.d.ts +81 -0
  169. package/lib/sync/sync-config.js +281 -0
  170. package/lib/sync/sync-engine.d.ts +269 -0
  171. package/lib/sync/sync-engine.js +591 -0
  172. package/lib/sync/sync-history.d.ts +42 -0
  173. package/lib/sync/sync-history.js +78 -0
  174. package/lib/sync/sync-selection.d.ts +29 -0
  175. package/lib/sync/sync-selection.js +127 -0
  176. package/lib/sync/sync-session.d.ts +40 -0
  177. package/lib/sync/sync-session.js +54 -0
  178. package/lib/sync/sync-state.d.ts +37 -0
  179. package/lib/sync/sync-state.js +108 -0
  180. package/lib/sync/transport.d.ts +75 -0
  181. package/lib/sync/transport.js +55 -0
  182. package/lib/sync/ui-prefs.d.ts +34 -0
  183. package/lib/sync/ui-prefs.js +109 -0
  184. package/lib/sync/webdav/webdav-transport.d.ts +77 -0
  185. package/lib/sync/webdav/webdav-transport.js +451 -0
  186. package/lib/ui/errors.d.ts +19 -0
  187. package/lib/ui/errors.js +81 -0
  188. package/lib/ui/export-flow.d.ts +82 -0
  189. package/lib/ui/export-flow.js +98 -0
  190. package/lib/ui/i18n.d.ts +289 -0
  191. package/lib/ui/i18n.js +581 -0
  192. package/lib/ui/migration-consult-view.d.ts +91 -0
  193. package/lib/ui/migration-consult-view.js +115 -0
  194. package/lib/ui/progress.d.ts +38 -0
  195. package/lib/ui/progress.js +87 -0
  196. package/lib/ui/report.d.ts +23 -0
  197. package/lib/ui/report.js +153 -0
  198. package/lib/ui/test-helpers.d.ts +69 -0
  199. package/lib/ui/test-helpers.js +154 -0
  200. package/lib/ui/types.d.ts +257 -0
  201. package/lib/ui/types.js +21 -0
  202. package/lib/utils/atomic-write.d.ts +88 -0
  203. package/lib/utils/atomic-write.js +399 -0
  204. package/lib/utils/env-lock.d.ts +393 -0
  205. package/lib/utils/env-lock.js +930 -0
  206. package/lib/utils/hashing.d.ts +17 -0
  207. package/lib/utils/hashing.js +41 -0
  208. package/lib/utils/json.d.ts +24 -0
  209. package/lib/utils/json.js +76 -0
  210. package/lib/utils/logger.d.ts +39 -0
  211. package/lib/utils/logger.js +82 -0
  212. package/lib/utils/paths.d.ts +42 -0
  213. package/lib/utils/paths.js +189 -0
  214. package/lib/utils/proxy.d.ts +69 -0
  215. package/lib/utils/proxy.js +455 -0
  216. package/lib/utils/recursive-walk.d.ts +24 -0
  217. package/lib/utils/recursive-walk.js +134 -0
  218. package/lib/utils/zip.d.ts +54 -0
  219. package/lib/utils/zip.js +285 -0
  220. package/package.json +188 -0
  221. package/src/adapters/agent-instructions.ts +48 -0
  222. package/src/adapters/agent-presets.ts +14 -0
  223. package/src/adapters/credentials.test.ts +90 -0
  224. package/src/adapters/credentials.ts +143 -0
  225. package/src/adapters/file-collection.ts +137 -0
  226. package/src/adapters/files.test.ts +353 -0
  227. package/src/adapters/index.ts +94 -0
  228. package/src/adapters/link-report.test.ts +65 -0
  229. package/src/adapters/link-report.ts +61 -0
  230. package/src/adapters/mcp.test.ts +96 -0
  231. package/src/adapters/mcp.ts +185 -0
  232. package/src/adapters/plugin-files.ts +146 -0
  233. package/src/adapters/plugins.test.ts +326 -0
  234. package/src/adapters/plugins.ts +615 -0
  235. package/src/adapters/pnpm-workspace.test.ts +87 -0
  236. package/src/adapters/pnpm-workspace.ts +150 -0
  237. package/src/adapters/prompts.test.ts +110 -0
  238. package/src/adapters/prompts.ts +230 -0
  239. package/src/adapters/providers.test.ts +126 -0
  240. package/src/adapters/providers.ts +173 -0
  241. package/src/adapters/roundtrip.test.ts +190 -0
  242. package/src/adapters/self.test.ts +92 -0
  243. package/src/adapters/self.ts +75 -0
  244. package/src/adapters/sessions.ts +15 -0
  245. package/src/adapters/settings.test.ts +100 -0
  246. package/src/adapters/settings.ts +187 -0
  247. package/src/adapters/skills.ts +14 -0
  248. package/src/adapters/test-helpers.ts +248 -0
  249. package/src/adapters/ui.test.ts +57 -0
  250. package/src/adapters/ui.ts +108 -0
  251. package/src/adapters/workspaces.test.ts +106 -0
  252. package/src/adapters/workspaces.ts +109 -0
  253. package/src/client/ConfigManagerSection.tsx +90 -0
  254. package/src/client/api.ts +66 -0
  255. package/src/client/client-types.ts +18 -0
  256. package/src/client/common/ErrorBanner.tsx +73 -0
  257. package/src/client/common/Icon.tsx +129 -0
  258. package/src/client/common/Modal.tsx +173 -0
  259. package/src/client/common/ToastViewport.tsx +73 -0
  260. package/src/client/common/toast-store.test.ts +192 -0
  261. package/src/client/common/toast-store.ts +181 -0
  262. package/src/client/common/toast-types.ts +7 -0
  263. package/src/client/common/ui.tsx +342 -0
  264. package/src/client/config-manager.module.css +2971 -0
  265. package/src/client/css-modules.d.ts +8 -0
  266. package/src/client/index.ts +65 -0
  267. package/src/client/locales.ts +56 -0
  268. package/src/client/lucide-icons.d.ts +38 -0
  269. package/src/client/run-store.test.ts +223 -0
  270. package/src/client/run-store.ts +372 -0
  271. package/src/client/sync/SyncConfirmView.tsx +379 -0
  272. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  273. package/src/client/sync/SyncHistoryView.tsx +220 -0
  274. package/src/client/sync/SyncSettingsView.tsx +1535 -0
  275. package/src/client/sync/history-model.test.ts +194 -0
  276. package/src/client/sync/history-model.ts +195 -0
  277. package/src/client/sync/sync-api.test.ts +585 -0
  278. package/src/client/sync/sync-api.ts +543 -0
  279. package/src/client/sync/sync-locales.ts +416 -0
  280. package/src/client/sync/sync-push-preview.test.ts +52 -0
  281. package/src/client/sync/sync-view-v2.test.ts +213 -0
  282. package/src/client/sync/sync-view.test.ts +487 -0
  283. package/src/client/sync/sync-view.ts +759 -0
  284. package/src/core/analyzer.ts +1068 -0
  285. package/src/core/backup.ts +605 -0
  286. package/src/core/boot-rescue.test.ts +847 -0
  287. package/src/core/boot-rescue.ts +737 -0
  288. package/src/core/cache-cleaner.test.ts +169 -0
  289. package/src/core/cache-cleaner.ts +121 -0
  290. package/src/core/config-lifecycle.test.ts +727 -0
  291. package/src/core/config-lifecycle.ts +573 -0
  292. package/src/core/config-snapshot.test.ts +369 -0
  293. package/src/core/config-snapshot.ts +522 -0
  294. package/src/core/config-state.test.ts +381 -0
  295. package/src/core/config-state.ts +207 -0
  296. package/src/core/consult-source.test.ts +199 -0
  297. package/src/core/consult-source.ts +293 -0
  298. package/src/core/crash-report.test.ts +380 -0
  299. package/src/core/crash-report.ts +369 -0
  300. package/src/core/exporter.ts +417 -0
  301. package/src/core/ghost-sweep.test.ts +94 -0
  302. package/src/core/ghost-sweep.ts +67 -0
  303. package/src/core/importer.ts +83 -0
  304. package/src/core/index.ts +56 -0
  305. package/src/core/journal.test.ts +257 -0
  306. package/src/core/journal.ts +518 -0
  307. package/src/core/local-plugin-host.ts +152 -0
  308. package/src/core/local-plugin-pack.test.ts +359 -0
  309. package/src/core/local-plugin-pack.ts +363 -0
  310. package/src/core/messages.test.ts +54 -0
  311. package/src/core/messages.ts +664 -0
  312. package/src/core/migration-consult.test.ts +315 -0
  313. package/src/core/migration-consult.ts +494 -0
  314. package/src/core/migration-history.test.ts +319 -0
  315. package/src/core/migration-history.ts +484 -0
  316. package/src/core/model-tools.test.ts +190 -0
  317. package/src/core/model-tools.ts +208 -0
  318. package/src/core/msg-types.ts +10 -0
  319. package/src/core/phase3-child-crash.ts +95 -0
  320. package/src/core/phase3-host.ts +411 -0
  321. package/src/core/phase3-p1.test.ts +212 -0
  322. package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
  323. package/src/core/phase3-prod-child.ts +36 -0
  324. package/src/core/phase3-production-integration.test.ts +286 -0
  325. package/src/core/phase4-crash-child.ts +101 -0
  326. package/src/core/phase4-crash-injection.test.ts +166 -0
  327. package/src/core/plugin-cli.fs.test.ts +184 -0
  328. package/src/core/plugin-cli.test.ts +137 -0
  329. package/src/core/plugin-cli.ts +563 -0
  330. package/src/core/reconcile.test.ts +392 -0
  331. package/src/core/reconcile.ts +498 -0
  332. package/src/core/recovery-orchestrator.test.ts +145 -0
  333. package/src/core/recovery-orchestrator.ts +366 -0
  334. package/src/core/restore-manage.test.ts +94 -0
  335. package/src/core/restore.ts +849 -0
  336. package/src/core/rollback.ts +151 -0
  337. package/src/core/run-progress.test.ts +237 -0
  338. package/src/core/run-registry.test.ts +232 -0
  339. package/src/core/run-registry.ts +209 -0
  340. package/src/core/smoke.test.ts +842 -0
  341. package/src/core/startup-barrier.ts +99 -0
  342. package/src/core/types.ts +542 -0
  343. package/src/core/undo.ts +116 -0
  344. package/src/core/validator.ts +84 -0
  345. package/src/core/verify-recovery.ts +265 -0
  346. package/src/core/watcher.test.ts +326 -0
  347. package/src/core/watcher.ts +263 -0
  348. package/src/index.facade.test.ts +265 -0
  349. package/src/index.sync.test.ts +217 -0
  350. package/src/index.ts +2975 -0
  351. package/src/migrations/index.ts +71 -0
  352. package/src/migrations/v1-to-v2.ts +17 -0
  353. package/src/profiles/index.ts +8 -0
  354. package/src/profiles/profile-manager.test.ts +282 -0
  355. package/src/profiles/profile-manager.ts +630 -0
  356. package/src/schema/config.ts +165 -0
  357. package/src/schema/index.ts +71 -0
  358. package/src/schema/manifest-schema.test.ts +519 -0
  359. package/src/schema/manifest.ts +124 -0
  360. package/src/schema/tombstones.test.ts +131 -0
  361. package/src/schema/tombstones.ts +89 -0
  362. package/src/schema/types.ts +245 -0
  363. package/src/schema/versions.ts +55 -0
  364. package/src/security/encryption.ts +335 -0
  365. package/src/security/index.ts +16 -0
  366. package/src/security/integrity.ts +102 -0
  367. package/src/security/redaction.ts +101 -0
  368. package/src/security/secret-scanner.ts +445 -0
  369. package/src/security/security.test.ts +1215 -0
  370. package/src/security/vault.test.ts +164 -0
  371. package/src/security/vault.ts +198 -0
  372. package/src/security/zip-security.ts +180 -0
  373. package/src/sync/autosync-config.test.ts +147 -0
  374. package/src/sync/autosync-config.ts +179 -0
  375. package/src/sync/autosync-scheduler.test.ts +490 -0
  376. package/src/sync/autosync-scheduler.ts +646 -0
  377. package/src/sync/fs.ts +48 -0
  378. package/src/sync/git/git-transport.test.ts +673 -0
  379. package/src/sync/git/git-transport.ts +491 -0
  380. package/src/sync/github-auth.test.ts +280 -0
  381. package/src/sync/github-auth.ts +356 -0
  382. package/src/sync/layout.test.ts +270 -0
  383. package/src/sync/layout.ts +222 -0
  384. package/src/sync/snapshot-json.test.ts +119 -0
  385. package/src/sync/snapshot-json.ts +128 -0
  386. package/src/sync/sync-config.test.ts +310 -0
  387. package/src/sync/sync-config.ts +299 -0
  388. package/src/sync/sync-engine.test.ts +1074 -0
  389. package/src/sync/sync-engine.ts +754 -0
  390. package/src/sync/sync-history.test.ts +116 -0
  391. package/src/sync/sync-history.ts +120 -0
  392. package/src/sync/sync-selection.test.ts +186 -0
  393. package/src/sync/sync-selection.ts +143 -0
  394. package/src/sync/sync-session.test.ts +143 -0
  395. package/src/sync/sync-session.ts +77 -0
  396. package/src/sync/sync-state.test.ts +164 -0
  397. package/src/sync/sync-state.ts +131 -0
  398. package/src/sync/transport.test.ts +111 -0
  399. package/src/sync/transport.ts +118 -0
  400. package/src/sync/ui-prefs.test.ts +152 -0
  401. package/src/sync/ui-prefs.ts +135 -0
  402. package/src/sync/webdav/webdav-transport.test.ts +871 -0
  403. package/src/sync/webdav/webdav-transport.ts +535 -0
  404. package/src/ui/errors.test.ts +47 -0
  405. package/src/ui/errors.ts +100 -0
  406. package/src/ui/export-flow.test.ts +87 -0
  407. package/src/ui/export-flow.ts +156 -0
  408. package/src/ui/i18n.ts +590 -0
  409. package/src/ui/migration-consult-view.test.ts +151 -0
  410. package/src/ui/migration-consult-view.ts +178 -0
  411. package/src/ui/progress.test.ts +47 -0
  412. package/src/ui/progress.ts +102 -0
  413. package/src/ui/report.test.ts +101 -0
  414. package/src/ui/report.ts +146 -0
  415. package/src/ui/test-helpers.ts +184 -0
  416. package/src/ui/types.ts +325 -0
  417. package/src/utils/atomic-write.test.ts +312 -0
  418. package/src/utils/atomic-write.ts +422 -0
  419. package/src/utils/env-lock.test.ts +999 -0
  420. package/src/utils/env-lock.ts +1086 -0
  421. package/src/utils/hashing.ts +53 -0
  422. package/src/utils/json.ts +77 -0
  423. package/src/utils/logger.ts +114 -0
  424. package/src/utils/paths.ts +176 -0
  425. package/src/utils/proxy.test.ts +465 -0
  426. package/src/utils/proxy.ts +493 -0
  427. package/src/utils/recursive-walk.test.ts +148 -0
  428. package/src/utils/recursive-walk.ts +145 -0
  429. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,1215 @@
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 { CredentialsAdapter } from '../adapters/credentials.ts';
20
+ import { parseManifest } from '../schema/manifest.ts';
21
+ import type {
22
+ ConfigAdapter, CredentialsFacade, ExportSection, FileSystemFacade, HostContext,
23
+ NamespaceInfo, PatchFileFacade, PluginsFacade, SettingsFacade, SnapshotStore,
24
+ WorkspaceFacade,
25
+ } from '../core/types.ts';
26
+ import type { NamespaceRecord, SettingsSection, WorkspaceRecord } from '../schema/types.ts';
27
+ import type { Snapshot } from '../core/types.ts';
28
+
29
+ import {
30
+ createSecretScanner, createConfiguredSecretScanner, scanAndRedact, scanText,
31
+ isSensitiveFieldName, matchSecretValuePattern, REDACTED_PLACEHOLDER,
32
+ DEFAULT_SECRET_FIELD_NAMES,
33
+ } from './secret-scanner.ts';
34
+ import type { ValuePattern, ConfiguredSecretPatterns } from './secret-scanner.ts';
35
+ import {
36
+ encryptCredentials, decryptCredentials, createEncryptionProvider,
37
+ SecurityError, SCHEMA_MAGIC, SCHEMA_VERSION, HEADER_LENGTH, SALT_LENGTH, IV_LENGTH,
38
+ SCRYPT_PARAMS,
39
+ encryptArchive, decryptArchive, verifyEncryptedBlob, isArchiveBlob, ARCHIVE_MAGIC,
40
+ } from './encryption.ts';
41
+ import {
42
+ buildChecksums, parseChecksumsTable, verifyChecksums, verifyChecksumsJson, describeMismatches,
43
+ } from './integrity.ts';
44
+ import {
45
+ parseZipHardened, createHardenedZipParser, safeExtractHardened, isExecutableName,
46
+ } from './zip-security.ts';
47
+ import { redact, redactValue, createRedactor, REDACTED } from './redaction.ts';
48
+
49
+ /* ================= 工具 ================= */
50
+
51
+ /** 手工构造原始 ZIP(任意条目名 / externalAttrs,绕过写侧校验,用于读侧攻击测试) */
52
+ function buildRawZip(entryName: string, data: Uint8Array, externalAttrs = 0): Uint8Array {
53
+ const nameBuf = Buffer.from(entryName, 'utf8');
54
+ const crc = crc32(data);
55
+ const local = Buffer.alloc(30);
56
+ local.writeUInt32LE(0x04034b50, 0);
57
+ local.writeUInt16LE(20, 4);
58
+ local.writeUInt16LE(0x0800, 6);
59
+ local.writeUInt16LE(0, 8); // store
60
+ local.writeUInt32LE(crc, 14);
61
+ local.writeUInt32LE(data.length, 18);
62
+ local.writeUInt32LE(data.length, 22);
63
+ local.writeUInt16LE(nameBuf.length, 26);
64
+ local.writeUInt16LE(0, 28);
65
+ const cen = Buffer.alloc(46);
66
+ cen.writeUInt32LE(0x02014b50, 0);
67
+ cen.writeUInt16LE(20, 4);
68
+ cen.writeUInt16LE(20, 6);
69
+ cen.writeUInt16LE(0x0800, 8);
70
+ cen.writeUInt16LE(0, 10);
71
+ cen.writeUInt32LE(crc, 16);
72
+ cen.writeUInt32LE(data.length, 20);
73
+ cen.writeUInt32LE(data.length, 24);
74
+ cen.writeUInt16LE(nameBuf.length, 28);
75
+ cen.writeUInt32LE(0, 34);
76
+ cen.writeUInt32LE(externalAttrs, 38);
77
+ cen.writeUInt32LE(30 + nameBuf.length, 42);
78
+ const eocd = Buffer.alloc(22);
79
+ eocd.writeUInt32LE(0x06054b50, 0);
80
+ eocd.writeUInt16LE(1, 8);
81
+ eocd.writeUInt16LE(1, 10);
82
+ eocd.writeUInt32LE(46 + nameBuf.length, 12);
83
+ eocd.writeUInt32LE(30 + nameBuf.length + data.length, 16);
84
+ return Buffer.concat([local, nameBuf, data, cen, nameBuf, eocd]);
85
+ }
86
+
87
+ interface RawEntry { name: string; data: Uint8Array; externalAttrs?: number }
88
+ function buildRawZipMany(entries: RawEntry[]): Uint8Array {
89
+ const localBlocks: Buffer[] = [];
90
+ const centralBlocks: Buffer[] = [];
91
+ let offset = 0;
92
+ for (const e of entries) {
93
+ const nameBuf = Buffer.from(e.name, 'utf8');
94
+ const crc = crc32(e.data);
95
+ const local = Buffer.alloc(30);
96
+ local.writeUInt32LE(0x04034b50, 0);
97
+ local.writeUInt16LE(20, 4);
98
+ local.writeUInt16LE(0x0800, 6);
99
+ local.writeUInt16LE(0, 8);
100
+ local.writeUInt32LE(crc, 14);
101
+ local.writeUInt32LE(e.data.length, 18);
102
+ local.writeUInt32LE(e.data.length, 22);
103
+ local.writeUInt16LE(nameBuf.length, 26);
104
+ local.writeUInt16LE(0, 28);
105
+ localBlocks.push(local, nameBuf, Buffer.from(e.data));
106
+ const cen = Buffer.alloc(46);
107
+ cen.writeUInt32LE(0x02014b50, 0);
108
+ cen.writeUInt16LE(20, 4);
109
+ cen.writeUInt16LE(20, 6);
110
+ cen.writeUInt16LE(0x0800, 8);
111
+ cen.writeUInt16LE(0, 10);
112
+ cen.writeUInt32LE(crc, 16);
113
+ cen.writeUInt32LE(e.data.length, 20);
114
+ cen.writeUInt32LE(e.data.length, 24);
115
+ cen.writeUInt16LE(nameBuf.length, 28);
116
+ cen.writeUInt32LE(0, 34);
117
+ cen.writeUInt32LE(e.externalAttrs ?? 0, 38);
118
+ cen.writeUInt32LE(offset, 42);
119
+ centralBlocks.push(cen, nameBuf);
120
+ offset += 30 + nameBuf.length + e.data.length;
121
+ }
122
+ const cd = Buffer.concat(centralBlocks);
123
+ const eocd = Buffer.alloc(22);
124
+ eocd.writeUInt32LE(0x06054b50, 0);
125
+ eocd.writeUInt16LE(entries.length, 8);
126
+ eocd.writeUInt16LE(entries.length, 10);
127
+ eocd.writeUInt32LE(cd.length, 12);
128
+ eocd.writeUInt32LE(offset, 16);
129
+ eocd.writeUInt16LE(0, 20);
130
+ return Buffer.concat([...localBlocks, cd, eocd]);
131
+ }
132
+
133
+ async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
134
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-sec-'));
135
+ try {
136
+ return await fn(dir);
137
+ } finally {
138
+ await fs.rm(dir, { recursive: true, force: true });
139
+ }
140
+ }
141
+
142
+ /* ================= 内存 mock(core 注入点集成测试用,精简自 smoke.test.ts) ================= */
143
+
144
+ class MemFs implements FileSystemFacade {
145
+ files = new Map<string, Uint8Array>();
146
+ private readonly homeDir: string;
147
+ constructor(homeDir: string) {
148
+ this.homeDir = homeDir;
149
+ }
150
+ private key(p: string): string {
151
+ return normalizePath(path.resolve(this.homeDir, p));
152
+ }
153
+ async readFile(relPath: string): Promise<Uint8Array> {
154
+ const v = this.files.get(this.key(relPath));
155
+ if (v === undefined) throw new Error(`ENOENT: ${relPath}`);
156
+ return v;
157
+ }
158
+ async writeFile(relPath: string, data: Uint8Array): Promise<void> {
159
+ this.files.set(this.key(relPath), data);
160
+ }
161
+ async exists(relPath: string): Promise<boolean> {
162
+ return this.files.has(this.key(relPath));
163
+ }
164
+ async copy(from: string, to: string): Promise<void> {
165
+ const v = this.files.get(this.key(from));
166
+ if (v === undefined) throw new Error(`ENOENT: ${from}`);
167
+ this.files.set(this.key(to), v);
168
+ }
169
+ async remove(relPath: string): Promise<void> {
170
+ this.files.delete(this.key(relPath));
171
+ }
172
+ async listRecursive(): Promise<string[]> { return []; }
173
+ async mkdir(): Promise<void> {}
174
+ }
175
+
176
+ class MemSettings implements SettingsFacade {
177
+ ns = new Map<string, NamespaceInfo>();
178
+ async describe(namespace: string): Promise<NamespaceInfo> {
179
+ const r = this.ns.get(namespace);
180
+ if (!r) throw new Error(`namespace not found: ${namespace}`);
181
+ return r;
182
+ }
183
+ async replace(namespace: string, value: unknown): Promise<void> {
184
+ const r = this.ns.get(namespace);
185
+ this.ns.set(namespace, { value, revision: (r?.revision ?? 0) + 1, secrets: r?.secrets ?? [] });
186
+ }
187
+ }
188
+
189
+ class MemCredentials implements CredentialsFacade {
190
+ values = new Map<string, string>();
191
+ async describe(ref: string) {
192
+ const v = this.values.get(ref);
193
+ return { configured: v !== undefined, source: v !== undefined ? 'file' : 'env', writable: true };
194
+ }
195
+ async set(ref: string, value: string) { this.values.set(ref, value); }
196
+ async unset(ref: string) { this.values.delete(ref); }
197
+ }
198
+
199
+ class MemPlugins implements PluginsFacade {
200
+ async listInstalled() { return []; }
201
+ async install(_pkg: string, _spec?: string) { return { needsRestart: true }; }
202
+ }
203
+
204
+ class MemWorkspace implements WorkspaceFacade {
205
+ records = new Map<string, WorkspaceRecord>();
206
+ async listRecords() { return [...this.records.values()]; }
207
+ async writeRecord(r: WorkspaceRecord) { this.records.set(r.id, r); }
208
+ }
209
+
210
+ class MemPatch implements PatchFileFacade {
211
+ async readPatchLines() { return []; }
212
+ async applyPatchChanges() {}
213
+ }
214
+
215
+ class MemSnapshotStore implements SnapshotStore {
216
+ snapshots = new Map<string, Snapshot>();
217
+ async save(s: Snapshot) { this.snapshots.set(s.id, s); return s.id; }
218
+ async load(id: string) {
219
+ const s = this.snapshots.get(id);
220
+ if (!s) throw new Error(`snapshot not found: ${id}`);
221
+ return s;
222
+ }
223
+ async readBlob() { return new Uint8Array(); }
224
+ async updateStatus(id: string, status: Snapshot['status']) {
225
+ const s = this.snapshots.get(id);
226
+ if (!s) throw new Error(`snapshot not found: ${id}`);
227
+ s.status = status;
228
+ }
229
+ }
230
+
231
+ class MockHostContext implements HostContext {
232
+ platform = 'win32';
233
+ arch = 'x64';
234
+ homeDir: string;
235
+ dshVersion = '0.1.0-rc.6';
236
+ log: Logger;
237
+ settings = new MemSettings();
238
+ credentials = new MemCredentials();
239
+ plugins = new MemPlugins();
240
+ workspace = new MemWorkspace();
241
+ patchFile = new MemPatch();
242
+ fs: MemFs;
243
+ constructor(homeDir: string) {
244
+ this.homeDir = homeDir;
245
+ this.fs = new MemFs(homeDir);
246
+ this.log = createLogger({ level: 'error', sink: () => {} });
247
+ }
248
+ }
249
+
250
+ class MiniSettingsAdapter implements ConfigAdapter<SettingsSection> {
251
+ readonly id = 'settings' as const;
252
+ readonly displayName = 'Settings';
253
+ readonly defaultIncluded = true;
254
+ readonly portability = 'portable' as const;
255
+
256
+ async export(ctx: HostContext): Promise<ExportSection<SettingsSection>> {
257
+ const namespaces: Record<string, NamespaceRecord> = {};
258
+ try {
259
+ const info = await ctx.settings.describe('general');
260
+ namespaces['general'] = { value: info.value, base: info.base, revision: info.revision, applies: info.applies, secrets: info.secrets };
261
+ } catch { /* 不存在则跳过 */ }
262
+ return { sectionId: 'settings', data: { version: 1, namespaces }, counts: { namespaces: Object.keys(namespaces).length }, warnings: [] };
263
+ }
264
+ async analyzeImport() { return []; }
265
+ async applyItem() { return { ok: true }; }
266
+ async validate() { return { valid: true, issues: [] }; }
267
+ }
268
+
269
+ /* ================= encryption ================= */
270
+
271
+ test('encryption: 往返加密解密一致(scrypt + AES-256-GCM)', async () => {
272
+ const plaintext = 'DEEPSEEK_API_KEY: sk-super-secret-123\nGITHUB_TOKEN: ghp_abcdefghijklmnopqrstuvwxyz\n';
273
+ const { blob, info } = await encryptCredentials(plaintext, 'correct horse battery');
274
+ // blob 布局
275
+ assert.equal(Buffer.from(blob.subarray(0, 4)).toString('ascii'), SCHEMA_MAGIC);
276
+ assert.equal(blob[4], SCHEMA_VERSION);
277
+ assert.equal(blob.length, HEADER_LENGTH + Buffer.byteLength(plaintext, 'utf8'));
278
+ assert.equal(info.algorithm, 'aes-256-gcm');
279
+ assert.equal(info.kdf, 'scrypt');
280
+ assert.deepEqual(info.kdfParams, { ...SCRYPT_PARAMS });
281
+ assert.equal(info.version, 1);
282
+ // 解密往返
283
+ const decrypted = await decryptCredentials(blob, info, 'correct horse battery');
284
+ assert.equal(decrypted, plaintext);
285
+ });
286
+
287
+ test('encryption: 错误密码 → BAD_PASSWORD(认证失败,不泄明文)', async () => {
288
+ const { blob, info } = await encryptCredentials('sk-super-secret', 'right-password');
289
+ await assert.rejects(
290
+ () => decryptCredentials(blob, info, 'wrong-password'),
291
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
292
+ );
293
+ });
294
+
295
+ test('encryption: 篡改 manifest 加密参数 → TAMPERED', async () => {
296
+ const { blob, info } = await encryptCredentials('sk-super-secret', 'pw-12345678');
297
+ // 篡改 info.authTag(元数据不一致)
298
+ const tampered = { ...info, authTag: Buffer.from('tampered').toString('base64') };
299
+ await assert.rejects(
300
+ () => decryptCredentials(blob, tampered, 'pw-12345678'),
301
+ (err: unknown) => err instanceof SecurityError && err.code === 'TAMPERED',
302
+ );
303
+ // 篡改 info.salt
304
+ await assert.rejects(
305
+ () => decryptCredentials(blob, { ...info, salt: Buffer.alloc(SALT_LENGTH, 1).toString('base64') }, 'pw-12345678'),
306
+ (err: unknown) => err instanceof SecurityError && err.code === 'TAMPERED',
307
+ );
308
+ // 篡改密文字节 → GCM 认证失败(归为 BAD_PASSWORD:密码错误或密文被改)
309
+ const flipped = Buffer.from(blob);
310
+ flipped[HEADER_LENGTH] = flipped[HEADER_LENGTH]! ^ 0xff;
311
+ await assert.rejects(
312
+ () => decryptCredentials(flipped, info, 'pw-12345678'),
313
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
314
+ );
315
+ });
316
+
317
+ test('encryption: 截断 blob → TAMPERED;坏 magic → UNSUPPORTED_FORMAT', async () => {
318
+ const { blob, info } = await encryptCredentials('secret', 'pw-12345678');
319
+ await assert.rejects(
320
+ () => decryptCredentials(blob.subarray(0, 10), info, 'pw-12345678'),
321
+ (err: unknown) => err instanceof SecurityError && err.code === 'TAMPERED',
322
+ );
323
+ const badMagic = Buffer.from(blob);
324
+ badMagic.write('XXXX', 0, 'ascii');
325
+ await assert.rejects(
326
+ () => decryptCredentials(badMagic, info, 'pw-12345678'),
327
+ (err: unknown) => err instanceof SecurityError && err.code === 'UNSUPPORTED_FORMAT',
328
+ );
329
+ // kdfParams 被篡改成超大 N(DoS 向量)→ 拒绝
330
+ await assert.rejects(
331
+ () => decryptCredentials(blob, { ...info, kdfParams: { N: 2 ** 24, r: 8, p: 1, keyLength: 32 } }, 'pw-12345678'),
332
+ (err: unknown) => err instanceof SecurityError && err.code === 'UNSUPPORTED_FORMAT',
333
+ );
334
+ });
335
+
336
+ test('encryption: 每次导出 salt/iv 随机;密码绝不出现在 info 与 blob 头', async () => {
337
+ const password = 'my-secret-password-123';
338
+ const r1 = await encryptCredentials('value', password);
339
+ const r2 = await encryptCredentials('value', password);
340
+ assert.notEqual(r1.info.salt, r2.info.salt);
341
+ assert.notEqual(r1.info.iv, r2.info.iv);
342
+ const infoJson = JSON.stringify(r1.info);
343
+ const infoKeys = Object.keys(r1.info);
344
+ assert.ok(!infoKeys.some((k) => k.toLowerCase().includes('password')), 'info 不得含 password 字段');
345
+ assert.ok(!infoJson.includes(password), '密码值不得进入 info');
346
+ assert.ok(!Buffer.from(r1.blob.subarray(0, HEADER_LENGTH)).toString('utf8').includes(password));
347
+ });
348
+
349
+ test('encryption: createEncryptionProvider 对齐 core EncryptionProvider 契约', async () => {
350
+ const provider = createEncryptionProvider('provider-pw-123');
351
+ const plaintext = 'REF: value';
352
+ const { blob, info } = await provider.encrypt(plaintext); // encrypt 无密码参数(闭包持有)
353
+ const decrypted = await provider.decrypt(blob, info, 'provider-pw-123');
354
+ assert.equal(decrypted, plaintext);
355
+ // decrypt 用调用方传入的密码(换密码解密支持)
356
+ await assert.rejects(
357
+ () => provider.decrypt(blob, info, 'another-pw-123'),
358
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
359
+ );
360
+ });
361
+
362
+
363
+
364
+
365
+ /* ---------------- 整体备份容器加密(encryptArchive / verifyEncryptedBlob / decryptArchive) ---------------- */
366
+
367
+ test('archive: 往返加解密一致(任意二进制 ZIP 字节无损)', async () => {
368
+ const zipBytes = Buffer.from('PK\x03\x04this-is-a-real-zip-binary-content\u0000\x01\x02', 'binary');
369
+ const password = 'archive-password-123';
370
+ const { blob } = await encryptArchive(zipBytes, password);
371
+ assert.ok(isArchiveBlob(blob), 'magic DCA1 可探测');
372
+ assert.equal(Buffer.from(blob.subarray(0, 4)).toString('ascii'), ARCHIVE_MAGIC);
373
+
374
+ const verified = await verifyEncryptedBlob(blob, password);
375
+ assert.equal(verified.valid, true);
376
+ assert.equal(verified.ok, true);
377
+ assert.ok(verified.info && verified.kdf, '应返回解密参数');
378
+
379
+ const decrypted = await decryptArchive(blob, verified.info!, verified.kdf!, password);
380
+ assert.deepEqual(Buffer.from(decrypted), zipBytes, '解出的明文 ZIP 字节必须无损一致');
381
+ });
382
+
383
+ test('archive: 密码错误 → BAD_PASSWORD(verify 与 decrypt 都拒绝,不泄明文)', async () => {
384
+ const zipBytes = Buffer.from('PK\x03\x04dsh-config-plaintext');
385
+ const password = 'right-password';
386
+ const { blob } = await encryptArchive(zipBytes, password);
387
+ const verified = await verifyEncryptedBlob(blob, 'wrong-password');
388
+ assert.equal(verified.valid, true);
389
+ assert.equal(verified.ok, false);
390
+ assert.equal(verified.code, 'BAD_PASSWORD');
391
+ await assert.rejects(
392
+ () => decryptArchive(blob, verified.info!, verified.kdf!, 'wrong-password'),
393
+ (err: unknown) => err instanceof SecurityError && err.code === 'BAD_PASSWORD',
394
+ );
395
+ });
396
+
397
+ test('archive: 非容器字节 → valid=false(体积不足判 TAMPERED;magic 不符判 UNSUPPORTED_FORMAT)', async () => {
398
+ const tooShort = Buffer.from('PK\x03\x04plain-zip-not-encrypted');
399
+ assert.equal(isArchiveBlob(tooShort), false);
400
+ const v = await verifyEncryptedBlob(tooShort, 'pw');
401
+ assert.equal(v.valid, false);
402
+ assert.equal(v.code, 'TAMPERED', '体积不足 → TAMPERED');
403
+
404
+ // 长度足够的非容器字节 → magic 不符 → UNSUPPORTED_FORMAT
405
+ const fullLen = Buffer.alloc(64);
406
+ fullLen.write('PK\x03\x04', 0, 'binary');
407
+ const v2 = await verifyEncryptedBlob(fullLen, 'pw');
408
+ assert.equal(v2.valid, false);
409
+ assert.equal(v2.code, 'UNSUPPORTED_FORMAT');
410
+ });
411
+
412
+ test('archive: 篡改密文 → verify ok=false(GCM 认证失败)', async () => {
413
+ const { blob } = await encryptArchive(Buffer.from('sensitive-archive'), 'archive-pw-123');
414
+ const tampered = Buffer.from(blob);
415
+ tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff;
416
+ const verified = await verifyEncryptedBlob(tampered, 'archive-pw-123');
417
+ assert.equal(verified.valid, true);
418
+ assert.equal(verified.ok, false, '篡改后密码验证必失败');
419
+ });
420
+
421
+ /* ================= zip-security ================= */
422
+
423
+ test('zip-security: parseZipHardened 兼容 core 正常 ZIP', () => {
424
+ const buf = zipToBuffer([
425
+ { name: 'a/b.txt', data: Buffer.from('hello') },
426
+ { name: 'c.json', data: Buffer.from('{"x":1}') },
427
+ ]);
428
+ const archive = parseZipHardened(buf);
429
+ assert.deepEqual(archive.names(), ['a/b.txt', 'c.json']);
430
+ assert.equal(Buffer.from(archive.readEntry('a/b.txt')).toString(), 'hello');
431
+ });
432
+
433
+ test('zip-security: Zip Slip / 绝对路径 / 盘符 / UNC / NUL 拒绝', () => {
434
+ const evil = [
435
+ ['../evil.txt', '目录穿越'],
436
+ ['..\\evil.txt', '反斜杠穿越'],
437
+ ['a/../../evil.txt', '深层穿越'],
438
+ ['/abs/path', '绝对路径'],
439
+ ['C:/evil.txt', '盘符'],
440
+ ['C:\\evil.txt', '盘符反斜杠'],
441
+ ['\\\\server\\share', 'UNC'],
442
+ ['a\0b.txt', 'NUL'],
443
+ ] as const;
444
+ for (const [name, label] of evil) {
445
+ assert.throws(() => parseZipHardened(buildRawZip(name, Buffer.from('x'))), ZipSafetyError, `应拒绝 ${label}: ${name}`);
446
+ }
447
+ });
448
+
449
+ test('zip-security: symlink 条目(external attrs S_IFLNK)拒绝', () => {
450
+ const symlinkAttrs = (0xa1ff << 16) >>> 0; // S_IFLNK | 0777
451
+ assert.throws(
452
+ () => parseZipHardened(buildRawZip('link', Buffer.from('target'), symlinkAttrs)),
453
+ /符号链接/,
454
+ );
455
+ // 普通文件 mode 不误伤
456
+ const normalAttrs = (0x81a4 << 16) >>> 0; // S_IFREG | 0644
457
+ const ok = parseZipHardened(buildRawZip('file.txt', Buffer.from('x'), normalAttrs));
458
+ assert.deepEqual(ok.names(), ['file.txt']);
459
+ });
460
+
461
+ test('zip-security: 重复条目名拒绝', () => {
462
+ const buf = buildRawZipMany([
463
+ { name: 'a.txt', data: Buffer.from('first') },
464
+ { name: 'a.txt', data: Buffer.from('second') },
465
+ ]);
466
+ assert.throws(() => parseZipHardened(buf), /重复/);
467
+ });
468
+
469
+ test('zip-security: 条目数超限 / 压缩体积超限拒绝', () => {
470
+ const two = buildRawZipMany([
471
+ { name: 'a.txt', data: Buffer.from('a') },
472
+ { name: 'b.txt', data: Buffer.from('b') },
473
+ ]);
474
+ assert.throws(() => parseZipHardened(two, { maxEntries: 1 }), /条目数/);
475
+ // 压缩体积上限(store 条目 compSize = 数据长度;2 条各 1B,上限 1 则第二条触发)
476
+ assert.throws(() => parseZipHardened(two, { maxCompressedBytes: 1 }), /压缩数据总量/);
477
+ });
478
+
479
+ test('zip-security: safeExtractHardened 正常解压 + 可执行文件告警', async () => {
480
+ await withTmp(async (dir) => {
481
+ const zipPath = path.join(dir, 'x.zip');
482
+ const destDir = path.join(dir, 'out');
483
+ await fs.writeFile(zipPath, zipToBuffer([
484
+ { name: 'custom/skills/a.md', data: Buffer.from('# skill') },
485
+ { name: 'plugin-files/x.sh', data: Buffer.from('#!/bin/sh') },
486
+ ]));
487
+ const result = await safeExtractHardened(zipPath, destDir);
488
+ assert.deepEqual(result.files.sort(), ['custom/skills/a.md', 'plugin-files/x.sh']);
489
+ assert.ok(result.warnings.some((w) => w.includes('x.sh')), '可执行文件应产生告警');
490
+ const content = await fs.readFile(path.join(destDir, 'custom', 'skills', 'a.md'), 'utf8');
491
+ assert.equal(content, '# skill');
492
+ });
493
+ });
494
+
495
+ test('zip-security: 解压中途失败 → 中止并完整清理目标目录', async () => {
496
+ await withTmp(async (dir) => {
497
+ const zipPath = path.join(dir, 'bomb.zip');
498
+ const destDir = path.join(dir, 'out');
499
+ // 条目 1 正常写入,条目 2 高压缩比(zip bomb 模拟)→ readEntry 触发 ratio 上限
500
+ await fs.writeFile(zipPath, zipToBuffer([
501
+ { name: 'ok.txt', data: Buffer.from('written-first') },
502
+ { name: 'bomb.txt', data: Buffer.from('a'.repeat(50_000)) },
503
+ ]));
504
+ await assert.rejects(
505
+ () => safeExtractHardened(zipPath, destDir, { maxRatio: 5 }),
506
+ ZipSafetyError,
507
+ );
508
+ await assert.rejects(() => fs.access(destDir), /ENOENT/, '失败后目标目录应被完整清理');
509
+ });
510
+ });
511
+
512
+ test('zip-security: createHardenedZipParser 签名对齐 parseZipOverride', () => {
513
+ const parser = createHardenedZipParser({ maxEntries: 100 });
514
+ const archive = parser(zipToBuffer([{ name: 'a.txt', data: Buffer.from('x') }]), { maxEntries: 50 });
515
+ assert.deepEqual(archive.names(), ['a.txt']);
516
+ assert.equal(isExecutableName('run.sh'), true);
517
+ assert.equal(isExecutableName('readme.md'), false);
518
+ });
519
+
520
+ /* ================= integrity ================= */
521
+
522
+ test('integrity: checksums 生成与校验(匹配/不符/缺失)', () => {
523
+ const entries = [
524
+ { name: 'config/settings.json', data: Buffer.from('{"a":1}') },
525
+ { name: 'custom/skills/a.md', data: Buffer.from('# hi') },
526
+ ];
527
+ const table = buildChecksums(entries);
528
+ assert.equal(table['config/settings.json'], sha256Hex(Buffer.from('{"a":1}')));
529
+ assert.equal(Object.keys(table).length, 2);
530
+
531
+ const map = new Map(entries.map((e) => [e.name, e.data]));
532
+ assert.deepEqual(verifyChecksums(map, table), { ok: true, mismatches: [], missing: [] });
533
+ // 内容被改 → mismatches
534
+ const tampered = new Map(map);
535
+ tampered.set('config/settings.json', Buffer.from('{"a":2}'));
536
+ const r1 = verifyChecksums(tampered, table);
537
+ assert.equal(r1.ok, false);
538
+ assert.deepEqual(r1.mismatches, ['config/settings.json']);
539
+ // 缺文件 → missing
540
+ const missing = new Map<string, Uint8Array>();
541
+ missing.set('config/settings.json', map.get('config/settings.json')!);
542
+ const r2 = verifyChecksums(missing, table);
543
+ assert.equal(r2.ok, false);
544
+ assert.deepEqual(r2.missing, ['custom/skills/a.md']);
545
+ assert.ok(describeMismatches(r2).includes('custom/skills/a.md'));
546
+ });
547
+
548
+ test('integrity: parseChecksumsTable 结构校验(hex/危险键/非法路径/非对象)', () => {
549
+ const table = parseChecksumsTable(JSON.stringify({ 'a/b.txt': sha256Hex(Buffer.from('x')) }));
550
+ assert.equal(table['a/b.txt'], sha256Hex(Buffer.from('x')));
551
+ // 非 hex 值
552
+ assert.throws(() => parseChecksumsTable(JSON.stringify({ 'a/b.txt': 'zzz' })), /SHA-256/);
553
+ // 危险键(原型污染;注意对象字面量 '__proto__' 会被当原型设置,须用字符串构造 JSON)
554
+ assert.throws(() => parseChecksumsTable(`{"__proto__": "${sha256Hex(Buffer.from('x'))}"}`), /危险键/);
555
+ assert.throws(() => parseChecksumsTable(`{"constructor": "${sha256Hex(Buffer.from('x'))}"}`), /危险键/);
556
+ // 反斜杠路径(非 ZIP 正斜杠形态)
557
+ assert.throws(() => parseChecksumsTable(JSON.stringify({ 'a\\b.txt': sha256Hex(Buffer.from('x')) })), /非法相对路径/);
558
+ // 非对象 / 值非 hex
559
+ assert.throws(() => parseChecksumsTable('[1,2]'), /必须是 JSON 对象/);
560
+ assert.throws(() => parseChecksumsTable(JSON.stringify({ 'a/b.txt': 'not-a-hash' })), /SHA-256/);
561
+ });
562
+
563
+ test('integrity: verifyChecksumsJson 一步到位(不匹配 → ok:false)', () => {
564
+ const entries = new Map<string, Uint8Array>([['a.txt', Buffer.from('x')]]);
565
+ const raw = JSON.stringify({ 'a.txt': sha256Hex(Buffer.from('y')) });
566
+ const result = verifyChecksumsJson(entries, raw);
567
+ assert.equal(result.ok, false);
568
+ assert.deepEqual(result.mismatches, ['a.txt']);
569
+ });
570
+
571
+ /* ================= secret-scanner ================= */
572
+
573
+ test('scanner: 捕获敏感字段并剥离(多形态/大小写不敏感),不改原对象', () => {
574
+ const original = {
575
+ apiKey: 'sk-abc123',
576
+ API_KEY: 'v1',
577
+ 'api-key': 'v2',
578
+ GithubToken: 'ghp_abcdefghijklmnopqrstuvwxyz',
579
+ client_secret: 'cs1',
580
+ privateKey: 'pk1',
581
+ Authorization: 'Bearer xyz',
582
+ passwd: 'p1',
583
+ theme: 'dark', // 不敏感
584
+ monkey: 'banana', // key 后缀不误伤
585
+ sessionIds: ['s1'], // session 不误伤
586
+ nested: { credential: 'c1', ok: true },
587
+ list: [{ token: 't1' }, { title: 'ok' }],
588
+ };
589
+ const before = structuredClone(original);
590
+ const { sanitized, hits } = scanAndRedact(original);
591
+ // 原对象未被修改
592
+ assert.deepEqual(original, before);
593
+ const s = sanitized as Record<string, unknown>;
594
+ assert.equal(s['apiKey'], REDACTED_PLACEHOLDER);
595
+ assert.equal(s['API_KEY'], REDACTED_PLACEHOLDER);
596
+ assert.equal(s['api-key'], REDACTED_PLACEHOLDER);
597
+ assert.equal(s['GithubToken'], REDACTED_PLACEHOLDER);
598
+ assert.equal(s['client_secret'], REDACTED_PLACEHOLDER);
599
+ assert.equal(s['privateKey'], REDACTED_PLACEHOLDER);
600
+ assert.equal(s['Authorization'], REDACTED_PLACEHOLDER);
601
+ assert.equal(s['passwd'], REDACTED_PLACEHOLDER);
602
+ assert.equal(s['theme'], 'dark');
603
+ assert.equal(s['monkey'], 'banana');
604
+ assert.deepEqual(s['sessionIds'], ['s1']);
605
+ assert.equal((s['nested'] as Record<string, unknown>)['credential'], REDACTED_PLACEHOLDER);
606
+ assert.equal((s['nested'] as Record<string, unknown>)['ok'], true);
607
+ assert.equal(((s['list'] as unknown[])[0] as Record<string, unknown>)['token'], REDACTED_PLACEHOLDER);
608
+ // hits 记录路径
609
+ const paths = hits.map((h) => h.path).sort();
610
+ assert.ok(paths.includes('apiKey'));
611
+ assert.ok(paths.includes('list[0].token'));
612
+ assert.ok(paths.includes('nested.credential'));
613
+ assert.ok(hits.every((h) => !JSON.stringify(h).includes('sk-abc123')), 'hits 不得含值');
614
+ });
615
+
616
+ test('scanner: 引用豁免(apiKeyEnv 等只存名字的字段、env 名形态值)', () => {
617
+ const input = {
618
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
619
+ api_key_env: 'GITHUB_TOKEN',
620
+ apiKey: 'DEEPSEEK_API_KEY', // 值是 env 变量名形态 → 视为引用
621
+ realKey: 'sk-proj-9f8e7d6c5b4a3210abcdef', // 真实形态密钥 → 仍拦截
622
+ tokenRef: 'MY_TOKEN_REF',
623
+ };
624
+ const { sanitized } = scanAndRedact(input);
625
+ const s = sanitized as Record<string, unknown>;
626
+ assert.equal(s['apiKeyEnv'], 'DEEPSEEK_API_KEY');
627
+ assert.equal(s['api_key_env'], 'GITHUB_TOKEN');
628
+ assert.equal(s['apiKey'], 'DEEPSEEK_API_KEY');
629
+ assert.equal(s['realKey'], REDACTED_PLACEHOLDER);
630
+ assert.equal(s['tokenRef'], 'MY_TOKEN_REF');
631
+ });
632
+
633
+ test('scanner: 值形状启发式(字段名无关的 secret 形状也剥离)', () => {
634
+ const input = {
635
+ note: 'use sk-abc1234567890abc for api',
636
+ jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U',
637
+ aws: 'AKIAIOSFODNN7A1B2C3D4',
638
+ github: 'ghp_abcdefghijklmnopqrstuvwxyz',
639
+ pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...',
640
+ fine: 'just a note',
641
+ };
642
+ const { sanitized, hits } = scanAndRedact(input);
643
+ const s = sanitized as Record<string, unknown>;
644
+ assert.equal(s['note'], REDACTED_PLACEHOLDER, 'sk- 形状应剥离');
645
+ assert.equal(s['jwt'], REDACTED_PLACEHOLDER, 'JWT 应剥离');
646
+ assert.equal(s['aws'], REDACTED_PLACEHOLDER, 'AKIA 应剥离');
647
+ assert.equal(s['github'], REDACTED_PLACEHOLDER, 'ghp_ 应剥离');
648
+ assert.equal(s['pem'], REDACTED_PLACEHOLDER, 'PEM 私钥应剥离');
649
+ assert.equal(s['fine'], 'just a note');
650
+ assert.ok(hits.length >= 5);
651
+ assert.equal(matchSecretValuePattern('sk-abc1234567890abc'), 'openai-style-key');
652
+ assert.equal(matchSecretValuePattern('plain text'), null);
653
+ });
654
+
655
+ test('scanner: 值形状示例/占位形态放行(sk-your-*、AKIA...EXAMPLE、JWT 教学串)', () => {
656
+ const input = {
657
+ yourKey: 'sk-your-api-key-here', // 占位词 your
658
+ exampleKey: 'sk-example-key-12345', // 占位词 example
659
+ testKey: 'sk-test-key-1234567890', // 占位词 test
660
+ awsSample: 'AKIAIOSFODNN7EXAMPLE', // AWS 官方文档示例 key
661
+ jwtSample: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature', // 教学串第三段 signature
662
+ note: 'use sk-your-demo-key for api', // 文本叙述中的示例
663
+ };
664
+ const { sanitized, hits } = scanAndRedact(input);
665
+ const s = sanitized as Record<string, unknown>;
666
+ assert.equal(s['yourKey'], 'sk-your-api-key-here', 'sk-your-* 示例应放行');
667
+ assert.equal(s['exampleKey'], 'sk-example-key-12345', 'sk-example-* 示例应放行');
668
+ assert.equal(s['testKey'], 'sk-test-key-1234567890', 'sk-test-* 示例应放行');
669
+ assert.equal(s['awsSample'], 'AKIAIOSFODNN7EXAMPLE', 'AWS 官方示例应放行');
670
+ assert.equal(s['jwtSample'], 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature', 'JWT 教学串应放行');
671
+ assert.equal(s['note'], 'use sk-your-demo-key for api', '叙述中的示例应放行');
672
+ assert.equal(hits.length, 0);
673
+ });
674
+
675
+ test('scanner: 深层嵌套上限(≤64 通过,超限抛错)与循环引用防护', () => {
676
+ const deep = (n: number): unknown => {
677
+ let v: unknown = 'leaf';
678
+ for (let i = 0; i < n; i++) v = { x: v };
679
+ return v;
680
+ };
681
+ assert.doesNotThrow(() => scanAndRedact({ a: deep(60) }));
682
+ assert.throws(() => scanAndRedact({ a: deep(70) }), /深度/);
683
+ // 循环引用:不挂死,明确抛错
684
+ const cyclic: Record<string, unknown> = { name: 'x' };
685
+ cyclic['self'] = cyclic;
686
+ assert.throws(() => scanAndRedact(cyclic), /循环引用/);
687
+ });
688
+
689
+ test('scanner: Uint8Array 跳过、数组根、空字符串不报 hit、extra 名单扩展', () => {
690
+ const bin = new Uint8Array([1, 2, 3]);
691
+ const input = { data: bin, empty: '', clientSecret2: 'x', normal: 42 };
692
+ const { sanitized, hits } = scanAndRedact(input, { extraFieldNames: ['clientsecret2'] });
693
+ const s = sanitized as Record<string, unknown>;
694
+ assert.equal(s['data'], bin);
695
+ assert.equal(s['empty'], '');
696
+ assert.equal(s['clientSecret2'], REDACTED_PLACEHOLDER, 'extra 名单生效');
697
+ assert.equal(s['normal'], 42);
698
+ assert.equal(hits.length, 1);
699
+ // 数组根
700
+ const arr = scanAndRedact([{ token: 'x' }]);
701
+ assert.equal(((arr.sanitized as { token: string }[])[0] as { token: string }).token, REDACTED_PLACEHOLDER);
702
+ });
703
+
704
+ test('scanner: isSensitiveFieldName / createSecretScanner(core 契约)', () => {
705
+ assert.equal(isSensitiveFieldName('apiKey'), true);
706
+ assert.equal(isSensitiveFieldName('API_KEY'), true);
707
+ assert.equal(isSensitiveFieldName('github_token'), true);
708
+ assert.equal(isSensitiveFieldName('theme'), false);
709
+ assert.equal(isSensitiveFieldName('sessionIds'), false);
710
+ assert.equal(isSensitiveFieldName('monkey'), false);
711
+ assert.ok(DEFAULT_SECRET_FIELD_NAMES.includes('apikey'));
712
+
713
+ const scanner = createSecretScanner({ valuePatterns: true });
714
+ const r = scanner.scanAndRedact({ token: 'sk-abc1234567890abc' });
715
+ assert.equal((r.sanitized as Record<string, unknown>)['token'], REDACTED_PLACEHOLDER);
716
+ // scanText 可选方法存在(文件类分区文本扫描)
717
+ const textHits = scanner.scanText!('apiKey = sk-abc1234567890abc\nBearer xyz1234567890abcdef\nplain line');
718
+ assert.ok(textHits.length >= 2, `应命中字段形态与值形状,实际 ${textHits.length}`);
719
+ assert.ok(textHits.some((h) => h.path.startsWith('line:')));
720
+ });
721
+
722
+ test('scanText: 技能/代码文档中的示例命名不再误报(2026-08-20 优化)', () => {
723
+ // 真实技能文件(api-tester.md / frontend-developer.md 等)里出现过的代码示例形态 —— 全部应放行
724
+ const skillDoc = [
725
+ 'let authToken: string;', // 类型声明(值=string 类型词)
726
+ 'password: process.env.TEST_USER_PASSWORD', // 代码引用(含 . 与 ()—— 注意 field: value 正则截取 value 为 process.env.TEST_USER_PASSWORD
727
+ 'authToken = data.token;', // 赋值 + 成员访问
728
+ 'const token = loginResponse.json(\'token\');', // 方法调用
729
+ 'key={virtualItem.key}', // React key prop(key 不入敏感名单)
730
+ 'queryKey: [\'products\'],', // 数组字面量
731
+ 'token: your-token-here', // 占位符
732
+ 'api_key: <your-key>', // 占位符
733
+ 'max_tokens: 100', // 值=数字…… 但正则只匹配 \w+;"100" 是短标识符?100 长3 <20 → 放行 ✓
734
+ ].join('\n');
735
+ const hits = scanText(skillDoc);
736
+ assert.equal(hits.length, 0, `技能文档示例不应误报,实际: ${JSON.stringify(hits)}`);
737
+ });
738
+
739
+ test('scanText: 真实字面量密钥仍然拦截(优化不回退安全)', () => {
740
+ // 字段名敏感 + 真正的字面量长串 → 报
741
+ const real = [
742
+ 'password: s3cretP@ssw0rdXyZ2024', // 字段 password + 混合字面量长串
743
+ 'client_secret: abcdef1234567890abcdef12345678', // 字段敏感 + 长混合串
744
+ '"apiKey": "sk-proj-abcdef1234567890"', // JSON 形态 + sk- 值形状(强信号)
745
+ 'token=ghp_abcdefghijklmnopqrstuvwxyzABCDEF', // GitHub PAT 值形状
746
+ ].join('\n');
747
+ const hits = scanText(real);
748
+ assert.ok(hits.length >= 4, `真实密钥应全部拦截,实际: ${JSON.stringify(hits)} (${hits.length})`);
749
+ });
750
+
751
+ test('scanText: 占位/示例/模板引用/普通示例值放行(2026-08-21 优化)', () => {
752
+ const doc = [
753
+ 'token: ${OPENAI_API_KEY}', // 模板引用
754
+ 'api_key: {{MY_KEY}}', // 模板引用(双花括号)
755
+ 'secret: $ENV_SECRET', // $VAR 引用
756
+ 'password: your-token-here', // 占位符
757
+ 'token: abc-def-ghi-jkl', // 纯小写字母+连字符示例值(无数字/符号/大小写混合)
758
+ 'Bearer example-token-here', // 值形状示例形态
759
+ 'sk-your-api-key-here', // 值形状示例形态
760
+ 'use sk-demo-key-1234567890 in this doc', // 叙述中的示例
761
+ ].join('\n');
762
+ const hits = scanText(doc);
763
+ assert.equal(hits.length, 0, `示例文档不应误报,实际: ${JSON.stringify(hits)}`);
764
+ });
765
+
766
+ test('scanText: 含数字/符号的混合串仍是真实凭据特征 → 拦截(不回退)', () => {
767
+ const real = [
768
+ 'token: abc123-def456', // 含数字的混合串
769
+ 'password: AbCdEfGh-1234', // 大小写混合 + 数字 + 连字符(非纯字母数字,不落短标识符豁免)
770
+ 'apiKey: sk-proj-9f8e7d6c5b4a3210abcdef', // 真实形态 sk-(无占位词)
771
+ ].join('\n');
772
+ const hits = scanText(real);
773
+ assert.ok(hits.length >= 3, `真实凭据特征应拦截,实际: ${JSON.stringify(hits)} (${hits.length})`);
774
+ });
775
+
776
+ test('scanAndRedact: 宽松档(literalValueOnly,市场发布)占位/模板/代码引用放行,真凭据仍拦', () => {
777
+ // 宽松档:字段名敏感但值不是秘密 → 全部放行(市场发布场景,消除误报)
778
+ const benign = {
779
+ token: 'your-token',
780
+ apiKey: '${OPENAI_API_KEY}',
781
+ secret: '{{MY_SECRET}}',
782
+ password: 'process.env.PASSWORD',
783
+ Authorization: 'Bearer <token>',
784
+ clientSecret: 'client-secret-value', // 混合字符但纯小写+连字符(无数字/符号/大小写混合)→ 非凭据
785
+ max_tokens: 100,
786
+ note: 'just a note',
787
+ };
788
+ const r = scanAndRedact(benign, { literalValueOnly: true });
789
+ assert.equal(r.hits.length, 0, `宽松档不应误报,实际: ${JSON.stringify(r.hits)}`);
790
+ assert.deepEqual(r.sanitized, benign);
791
+
792
+ // 宽松档:真实凭据仍然拦截(安全不回退)
793
+ const real = {
794
+ token: 'sk-proj-9f8e7d6c5b4a3210abcdef',
795
+ Authorization: 'Bearer xyz1234567890abcdef',
796
+ clientSecret: 's3cretP@ssw0rdXyZ2024',
797
+ };
798
+ const rr = scanAndRedact(real, { literalValueOnly: true });
799
+ assert.equal(rr.hits.length, 3, `宽松档真凭据仍应拦截,实际: ${JSON.stringify(rr.hits)}`);
800
+ });
801
+
802
+ test('scanAndRedact: 保守档(默认,导出/同步)字段名敏感即剥,不受宽松档影响', () => {
803
+ // 默认模式:字段名敏感 + 任意值 → 剥离(导出脱敏保持保守,行为不变)
804
+ const input = { token: 'your-token', apiKey: '${OPENAI_API_KEY}' };
805
+ const { sanitized } = scanAndRedact(input);
806
+ const s = sanitized as Record<string, unknown>;
807
+ assert.equal(s['token'], REDACTED_PLACEHOLDER, '保守档字段名敏感即剥(占位符值也剥)');
808
+ assert.equal(s['apiKey'], REDACTED_PLACEHOLDER, '保守档模板引用值也剥');
809
+ });
810
+
811
+ test('scanner: extraValuePatterns 命中个人化值形状(默认模式不受影响)', () => {
812
+ // 部署者注入的个人化形状(F2 个人隐私规则:昵称/用户名格式,不进开源代码)
813
+ const personalPatterns: ValuePattern[] = [
814
+ { name: 'personal-nickname', re: /nick_[a-z0-9]{6,}/ },
815
+ { name: 'personal-handle', re: /@[a-z][a-z0-9]{5,}/ },
816
+ ];
817
+ // 单参(无附加模式)不命中 → 默认行为不变
818
+ assert.equal(matchSecretValuePattern('nick_abc12345'), null);
819
+ assert.equal(matchSecretValuePattern('@someuser123'), null);
820
+ // 双参(附加模式)命中
821
+ assert.equal(matchSecretValuePattern('nick_abc12345', personalPatterns), 'personal-nickname');
822
+ assert.equal(matchSecretValuePattern('@someuser123', personalPatterns), 'personal-handle');
823
+ // 附加模式同样降噪:命中片段含占位词 your → 放行
824
+ assert.equal(matchSecretValuePattern('@yourname123', personalPatterns), null);
825
+ // 默认内置模式不破坏:带附加模式时内置模式仍命中(内置优先)
826
+ assert.equal(matchSecretValuePattern('sk-abc1234567890abc', personalPatterns), 'openai-style-key');
827
+ assert.equal(matchSecretValuePattern('AKIAIOSFODNN7A1B2C3D4', personalPatterns), 'aws-access-key');
828
+
829
+ // 结构化扫描:字段名无关的值形状命中 → 剥离
830
+ const { sanitized, hits } = scanAndRedact(
831
+ { note: 'my nickname is nick_abc12345', handle: '@someuser123', fine: 'plain text' },
832
+ { extraValuePatterns: personalPatterns },
833
+ );
834
+ const s = sanitized as Record<string, unknown>;
835
+ assert.equal(s['note'], REDACTED_PLACEHOLDER, '附加形状命中剥离');
836
+ assert.equal(s['handle'], REDACTED_PLACEHOLDER, '@handle 形状命中剥离');
837
+ assert.equal(s['fine'], 'plain text');
838
+ assert.equal(hits.length, 2);
839
+ // 不带附加模式时:同样数据不剥离(默认模式不受影响)
840
+ const r2 = scanAndRedact({ note: 'my nickname is nick_abc12345' });
841
+ assert.equal((r2.sanitized as Record<string, unknown>)['note'], 'my nickname is nick_abc12345');
842
+ });
843
+
844
+ test('scanner: extraValuePatterns 宽松档同样生效 + scanText 值形状命中', () => {
845
+ const personalPatterns: ValuePattern[] = [
846
+ { name: 'personal-nickname', re: /nick_[a-z0-9]{6,}/ },
847
+ ];
848
+ // 宽松档(市场发布):字段名不敏感 + 附加形状命中 → 仍拦(值形状字段名无关)
849
+ const loose = scanAndRedact(
850
+ { note: 'nick_abc12345', token: 'your-token', real: 'sk-proj-9f8e7d6c5b4a3210abcdef' },
851
+ { literalValueOnly: true, extraValuePatterns: personalPatterns },
852
+ );
853
+ const s = loose.sanitized as Record<string, unknown>;
854
+ assert.equal(s['note'], REDACTED_PLACEHOLDER, '宽松档附加形状同样命中');
855
+ assert.equal(s['token'], 'your-token', '宽松档占位值仍放行');
856
+ assert.equal(s['real'], REDACTED_PLACEHOLDER, '宽松档内置形状仍命中');
857
+
858
+ // scanText:附加形状参与值形状行扫描(字段名无关强信号)
859
+ const textHits = scanText('contact: nick_abc12345\nplain line', { extraValuePatterns: personalPatterns });
860
+ assert.ok(
861
+ textHits.some((h) => h.field === '(personal-nickname)'),
862
+ `scanText 应报附加形状,实际 ${JSON.stringify(textHits)}`,
863
+ );
864
+ });
865
+
866
+ test('scanner: createConfiguredSecretScanner 部署配置构造(未配置=默认,配置生效)', () => {
867
+ const personalPatterns: ValuePattern[] = [
868
+ { name: 'personal-nickname', re: /nick_[a-z0-9]{6,}/ },
869
+ ];
870
+ // 未配置(undefined / 空对象 / 全空数组)→ 与默认 createSecretScanner 行为一致
871
+ const emptyConfigs: (ConfiguredSecretPatterns | undefined)[] = [
872
+ undefined, {}, { extraFieldNames: [] }, { extraValuePatterns: [] },
873
+ ];
874
+ for (const cfg of emptyConfigs) {
875
+ const scanner = createConfiguredSecretScanner(cfg);
876
+ const r = scanner.scanAndRedact({ note: 'nick_abc12345', token: 'x' });
877
+ const s = r.sanitized as Record<string, unknown>;
878
+ assert.equal(s['note'], 'nick_abc12345', '未配置时附加形状不生效');
879
+ assert.equal(s['token'], REDACTED_PLACEHOLDER, '默认敏感字段仍剥离');
880
+ }
881
+ // 配置生效:附加字段名 / 附加值形状 / 附加引用字段
882
+ const configured = createConfiguredSecretScanner({
883
+ extraFieldNames: ['nickname'],
884
+ extraReferenceFields: ['nicknameref'],
885
+ extraValuePatterns: personalPatterns,
886
+ });
887
+ const r = configured.scanAndRedact({
888
+ nickname: 'nick_abc12345', // 附加字段名敏感 → 剥
889
+ handle: 'nick_abc12345', // 附加形状命中(字段名无关)→ 剥
890
+ nickNameRef: 'NICK_REF_NAME', // 附加引用字段 → 豁免
891
+ token: 'your-token', // 默认字段仍剥
892
+ fine: 'plain',
893
+ });
894
+ const s = r.sanitized as Record<string, unknown>;
895
+ assert.equal(s['nickname'], REDACTED_PLACEHOLDER);
896
+ assert.equal(s['handle'], REDACTED_PLACEHOLDER);
897
+ assert.equal(s['nickNameRef'], 'NICK_REF_NAME');
898
+ assert.equal(s['token'], REDACTED_PLACEHOLDER);
899
+ assert.equal(s['fine'], 'plain');
900
+ // scanText 可选方法随配置生效
901
+ const textHits = configured.scanText!('nick_abc12345 in line');
902
+ assert.ok(
903
+ textHits.some((h) => h.field === '(personal-nickname)'),
904
+ `configured.scanText 应报附加形状,实际 ${JSON.stringify(textHits)}`,
905
+ );
906
+ });
907
+
908
+ /* ================= redaction ================= */
909
+
910
+ test('redaction: 结构化字段三形态 + 值形状 + URL query', () => {
911
+ const json = redact('{"apiKey": "sk-abc1234567890abc", "theme": "dark", "client_secret": "cs-value-1"}');
912
+ assert.ok(!json.includes('sk-abc1234567890abc'));
913
+ assert.ok(json.includes('"apiKey": "***REDACTED***"'));
914
+ assert.ok(json.includes('"client_secret": "***REDACTED***"'));
915
+ assert.ok(json.includes('"theme": "dark"'));
916
+ assert.doesNotThrow(() => JSON.parse(json), '输出仍是合法 JSON');
917
+
918
+ const kv = redact('token=abc123def456 theme=dark');
919
+ assert.ok(kv.includes('token=***REDACTED***'));
920
+ assert.ok(kv.includes('theme=dark'));
921
+
922
+ const colon = redact('Authorization: Bearer xyz1234567890abcdef');
923
+ assert.ok(colon.includes('Authorization: ***REDACTED***'));
924
+
925
+ const shape = redact('please use sk-abc1234567890abc now, aws key AKIAIOSFODNN7EXAMPLE');
926
+ assert.ok(!shape.includes('sk-abc1234567890abc'));
927
+ assert.ok(!shape.includes('AKIAIOSFODNN7EXAMPLE'));
928
+
929
+ const url = redact('https://x.com/api?token=secret-token-1&theme=dark&api_key=another-secret');
930
+ assert.ok(!url.includes('secret-token-1'));
931
+ assert.ok(!url.includes('another-secret'));
932
+ assert.ok(url.includes('theme=dark'));
933
+ });
934
+
935
+ test('redaction: 幂等(重复脱敏结果不变)+ createRedactor 与 redact 一致', () => {
936
+ const samples = [
937
+ '{"apiKey": "sk-abc1234567890abc", "theme": "dark"}',
938
+ 'token=abc123def456 theme=dark',
939
+ 'Authorization: Bearer xyz1234567890abcdef',
940
+ 'url?token=secret-token-1&api_key=another-secret',
941
+ 'plain text no secrets',
942
+ ];
943
+ for (const s of samples) {
944
+ const once = redact(s);
945
+ const twice = redact(once);
946
+ assert.equal(twice, once, `应幂等: ${s}`);
947
+ assert.equal(createRedactor()(s), once);
948
+ }
949
+ });
950
+
951
+ test('redaction: redactValue 对象级掩码 + 大小写 + 自定义附加名单', () => {
952
+ const masked = redactValue({ apiKey: 'sk-abc', nested: { clientSecret: 'cs', ok: 1 }, list: [{ token: 't' }] });
953
+ const m = masked as Record<string, unknown>;
954
+ assert.equal(m['apiKey'], REDACTED);
955
+ assert.equal((m['nested'] as Record<string, unknown>)['clientSecret'], REDACTED);
956
+ assert.equal((m['nested'] as Record<string, unknown>)['ok'], 1);
957
+ assert.equal(((m['list'] as unknown[])[0] as Record<string, unknown>)['token'], REDACTED);
958
+
959
+ assert.ok(redact('API_KEY=xyz123').includes('API_KEY=***REDACTED***'));
960
+ assert.ok(redact('monkey=banana').includes('monkey=banana'), '不敏感字段不受影响');
961
+ // 附加名单(规范化名)
962
+ const custom = createRedactor(['customfield']);
963
+ assert.ok(custom('customField=value123').includes('customField=***REDACTED***'));
964
+ });
965
+
966
+ /* ================= core 注入点集成 ================= */
967
+
968
+ test('集成: Exporter + createSecretScanner 剥离自定义敏感字段(第二道防线)', async () => {
969
+ await withTmp(async (dir) => {
970
+ const homeDir = path.join(dir, 'home');
971
+ const ctx = new MockHostContext(homeDir);
972
+ ctx.settings.ns.set('general', {
973
+ value: { theme: 'dark', apiKey: 'sk-super-secret-value', apiKeyEnv: 'DEEPSEEK_API_KEY' },
974
+ revision: 3,
975
+ secrets: [],
976
+ });
977
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
978
+ const zipPath = path.join(dir, 'x.zip');
979
+ await new Exporter({
980
+ ctx,
981
+ adapters,
982
+ scanner: createSecretScanner(), // 注入强化 scanner
983
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
984
+ }).export({ includeSecrets: false, outPath: zipPath });
985
+
986
+ const archive = parseZip(await fs.readFile(zipPath));
987
+ const settingsJson = archive.readEntryText('config/settings.json');
988
+ assert.ok(!settingsJson.includes('sk-super-secret-value'), 'secret 值不得写入导出');
989
+ assert.ok(settingsJson.includes('"apiKey": ""'), 'apiKey 应剥离为空串占位');
990
+ assert.ok(settingsJson.includes('"apiKeyEnv": "DEEPSEEK_API_KEY"'), '引用字段应保留');
991
+ assert.ok(settingsJson.includes('"theme": "dark"'));
992
+ });
993
+ });
994
+
995
+ test('集成: Exporter + EncryptionProvider 加密备份(secrets.enc + manifest + 解密恢复)', async () => {
996
+ await withTmp(async (dir) => {
997
+ const homeDir = path.join(dir, 'home');
998
+ const ctx = new MockHostContext(homeDir);
999
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
1000
+ const credentialsYaml = 'DEEPSEEK_API_KEY: sk-super-secret-value\nGITHUB_TOKEN: ghp_abcdefghijklmnopqrstuvwxyz\n';
1001
+ await ctx.fs.writeFile(path.join(homeDir, '.credentials.yaml'), Buffer.from(credentialsYaml, 'utf8'));
1002
+
1003
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
1004
+ const zipPath = path.join(dir, 'enc.zip');
1005
+ const password = 'backup-password-123';
1006
+ const exporter = new Exporter({
1007
+ ctx,
1008
+ adapters,
1009
+ scanner: createSecretScanner(),
1010
+ encryption: createEncryptionProvider(password),
1011
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
1012
+ });
1013
+ const { manifest } = await exporter.export({ includeSecrets: true, outPath: zipPath });
1014
+
1015
+ assert.equal(manifest.security.containsSecrets, true);
1016
+ assert.equal(manifest.security.encrypted, true);
1017
+ assert.ok(manifest.security.encryption, '应记录加密参数');
1018
+ assert.equal(manifest.security.encryption!.kdf, 'scrypt');
1019
+
1020
+ const archive = parseZip(await fs.readFile(zipPath));
1021
+ assert.ok(archive.has('security/secrets.enc'), 'secrets.enc 应写入 ZIP');
1022
+ // 密码绝不出现在 manifest 文本
1023
+ const manifestText = archive.readEntryText('manifest.json');
1024
+ assert.ok(!manifestText.includes(password), '密码不得出现在 manifest');
1025
+ assert.ok(!manifestText.includes('sk-super-secret-value'), '明文秘密不得出现在 manifest');
1026
+
1027
+ // 解密恢复
1028
+ const blob = archive.readEntry('security/secrets.enc');
1029
+ const decrypted = await decryptCredentials(blob, manifest.security.encryption!, password);
1030
+ assert.equal(decrypted, credentialsYaml);
1031
+ });
1032
+ });
1033
+
1034
+ test('集成: 加密备份导入强制密码——无解密结果拒绝,正确解密恢复凭据', async () => {
1035
+ await withTmp(async (dir) => {
1036
+ const homeDir = path.join(dir, 'home');
1037
+ const src = new MockHostContext(homeDir);
1038
+ src.settings.ns.set('general', { value: { theme: 'dark', apiKeyEnv: 'DEEPSEEK_API_KEY' }, revision: 1, secrets: [] });
1039
+ src.credentials.values.set('DEEPSEEK_API_KEY', 'sk-super-secret-value');
1040
+ await src.fs.writeFile(
1041
+ path.join(homeDir, '.credentials.yaml'),
1042
+ Buffer.from('DEEPSEEK_API_KEY: sk-super-secret-value\n', 'utf8'),
1043
+ );
1044
+ const credentialsAdapter = new CredentialsAdapter({ refs: async () => ['DEEPSEEK_API_KEY'] });
1045
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter(), credentialsAdapter];
1046
+ const zipPath = path.join(dir, 'enc.zip');
1047
+ const password = 'backup-password-123';
1048
+ await new Exporter({
1049
+ ctx: src,
1050
+ adapters,
1051
+ scanner: createSecretScanner(),
1052
+ encryption: createEncryptionProvider(password),
1053
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
1054
+ }).export({ includeSecrets: true, outPath: zipPath });
1055
+
1056
+ const dst = new MockHostContext(path.join(dir, 'dst-home'));
1057
+ const importer = new Importer({ ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
1058
+
1059
+ // 分析层必须暴露加密标志(UI 据此要求输入解密密码)
1060
+ const analysis = await importer.analyzeImport(zipPath);
1061
+ assert.equal(analysis.encrypted, true);
1062
+ assert.equal(analysis.secretCount, 1);
1063
+
1064
+ const plan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] });
1065
+ assert.ok(plan.missingSecrets.some((s) => s.ref === 'DEEPSEEK_API_KEY'));
1066
+
1067
+ // 1) 无解密结果:加密备份拒绝执行(不允许「无密码照样导入」)
1068
+ await assert.rejects(
1069
+ () => importer.executeImportPlan(zipPath, plan, { confirm: true }),
1070
+ /解密密码才能导入/,
1071
+ );
1072
+ assert.equal(dst.credentials.values.has('DEEPSEEK_API_KEY'), false, '拒绝时不得写入任何凭据');
1073
+
1074
+ // 2) 正确解密结果(宿主用备份密码解开 secrets.enc 注入):凭据恢复,不再要求补录
1075
+ const archive = parseZip(await fs.readFile(zipPath));
1076
+ const manifest = parseManifest(archive.readEntryText('manifest.json'));
1077
+ const blob = archive.readEntry('security/secrets.enc');
1078
+ const plaintext = await decryptCredentials(blob, manifest.security.encryption!, password);
1079
+ const map = new Map<string, string>();
1080
+ for (const line of plaintext.split('\n')) {
1081
+ const m = /^([A-Za-z0-9_]+):\s*(.+)$/.exec(line.trim());
1082
+ if (m) map.set(m[1]!, m[2]!);
1083
+ }
1084
+ const result = await importer.executeImportPlan(zipPath, plan, { confirm: true, decryptedCredentials: map });
1085
+ assert.equal(result.ok, true);
1086
+ assert.equal(result.missingSecrets.length, 0, '解密覆盖的凭据不再计入缺失');
1087
+ assert.equal(dst.credentials.values.get('DEEPSEEK_API_KEY'), 'sk-super-secret-value');
1088
+ });
1089
+ });
1090
+
1091
+ test('集成: includeSecrets 无加密提供者 → 拒绝(绝不明文导出秘密)', async () => {
1092
+ await withTmp(async (dir) => {
1093
+ const ctx = new MockHostContext(path.join(dir, 'home'));
1094
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
1095
+ const exporter = new Exporter({ ctx, adapters: [new MiniSettingsAdapter()], now: () => new Date() });
1096
+ await assert.rejects(
1097
+ () => exporter.export({ includeSecrets: true, outPath: path.join(dir, 'x.zip') }),
1098
+ /EncryptionProvider/,
1099
+ );
1100
+ });
1101
+ });
1102
+
1103
+ test('集成: 只加密不导出密钥(encryption 提供但 includeSecrets=false)→ 备份仍标记加密,但不含任何凭据值', async () => {
1104
+ await withTmp(async (dir) => {
1105
+ const homeDir = path.join(dir, 'home');
1106
+ const ctx = new MockHostContext(homeDir);
1107
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
1108
+ // 存在真实凭据文件,但用户只勾了「加密备份」、未勾「导出密钥」—— 凭据值绝不能进备份
1109
+ const credentialsYaml = 'DEEPSEEK_API_KEY: sk-super-secret-value\n';
1110
+ await ctx.fs.writeFile(path.join(homeDir, '.credentials.yaml'), Buffer.from(credentialsYaml, 'utf8'));
1111
+
1112
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
1113
+ const zipPath = path.join(dir, 'enc-only.zip');
1114
+ const password = 'backup-password-123';
1115
+ const exporter = new Exporter({
1116
+ ctx,
1117
+ adapters,
1118
+ scanner: createSecretScanner(),
1119
+ encryption: createEncryptionProvider(password),
1120
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
1121
+ });
1122
+ const { manifest } = await exporter.export({ includeSecrets: false, outPath: zipPath });
1123
+
1124
+ assert.equal(manifest.security.encrypted, true, '加密是独立选项:不含密钥也应标记加密');
1125
+ assert.equal(manifest.security.containsSecrets, false, '未导出密钥:不得声称包含秘密');
1126
+ assert.ok(manifest.security.encryption, '应记录加密参数');
1127
+
1128
+ const archive = parseZip(await fs.readFile(zipPath));
1129
+ assert.ok(archive.has('security/secrets.enc'), 'secrets.enc 应写入 ZIP');
1130
+ const blob = archive.readEntry('security/secrets.enc');
1131
+ const decrypted = await decryptCredentials(blob, manifest.security.encryption!, password);
1132
+ assert.equal(decrypted, '', '未导出密钥时 secrets.enc 解密内容必须为空');
1133
+ for (const name of archive.names()) {
1134
+ if (name === 'security/secrets.enc') continue // secrets.enc 为二进制加密内容,单独断言解密结果
1135
+ assert.ok(
1136
+ !archive.readEntryText(name).includes('sk-super-secret-value'),
1137
+ `凭据值绝不得出现在备份条目 ${name} 中`,
1138
+ )
1139
+ }
1140
+ });
1141
+ });
1142
+
1143
+ test('集成: 整体加密备份容器——导出→容器→解锁→明文 ZIP→分析(完整链路)', async () => {
1144
+ await withTmp(async (dir) => {
1145
+ const homeDir = path.join(dir, 'home');
1146
+ const ctx = new MockHostContext(homeDir);
1147
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
1148
+ await ctx.fs.writeFile(
1149
+ path.join(homeDir, '.credentials.yaml'),
1150
+ Buffer.from('DEEPSEEK_API_KEY: sk-super-secret-value\n', 'utf8'),
1151
+ );
1152
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
1153
+ const password = 'archive-whole-backup-pw';
1154
+
1155
+ // 1) Exporter 生成明文 ZIP(注入 EncryptionProvider 以便 includeSecrets 在 ZIP 内产出 secrets.enc;
1156
+ // 最终整体加密由外层容器完成)
1157
+ const plainZip = path.join(dir, 'plain.zip');
1158
+ await new Exporter({
1159
+ ctx,
1160
+ adapters,
1161
+ encryption: createEncryptionProvider(password),
1162
+ now: () => new Date(),
1163
+ }).export({ includeSecrets: true, outPath: plainZip });
1164
+
1165
+ // 2) 整体加密为容器
1166
+ const rawZip = await fs.readFile(plainZip);
1167
+ const { blob } = await encryptArchive(rawZip, password);
1168
+ const containerPath = path.join(dir, 'backup.zip');
1169
+ await fs.writeFile(containerPath, blob);
1170
+ assert.ok(isArchiveBlob(await fs.readFile(containerPath)), '落盘文件是加密容器(magic DCA1)');
1171
+
1172
+ // 3) 解锁(只读校验 + 解密)→ 明文 ZIP
1173
+ const containerBytes = await fs.readFile(containerPath);
1174
+ const verified = await verifyEncryptedBlob(containerBytes, password);
1175
+ assert.equal(verified.valid, true);
1176
+ assert.equal(verified.ok, true, '正确密码应通过验证');
1177
+ const plain = await decryptArchive(containerBytes, verified.info!, verified.kdf!, password);
1178
+ const unlockedPath = path.join(dir, 'unlocked.zip');
1179
+ await fs.writeFile(unlockedPath, plain);
1180
+
1181
+ // 4) 明文 ZIP 可被 Importer 正常分析
1182
+ const importer = new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() });
1183
+ const analysis = await importer.analyzeImport(unlockedPath);
1184
+ assert.equal(analysis.valid, true);
1185
+ assert.deepEqual(analysis.sectionsInZip, ['settings']);
1186
+
1187
+ // 5) 明文 ZIP 中确实包含完整凭据(includeSecrets=true 时 Exporter 在 ZIP 内写入 secrets.enc)
1188
+ const archive = parseZip(plain);
1189
+ assert.ok(archive.has('security/secrets.enc'), '容器解出的 ZIP 含 secrets.enc(凭据受容器整体保护)');
1190
+ // 错误密码:verify 拒绝
1191
+ const wrong = await verifyEncryptedBlob(containerBytes, 'wrong-password');
1192
+ assert.equal(wrong.ok, false);
1193
+ });
1194
+ });
1195
+
1196
+ test('集成: Importer + parseZipOverride=createHardenedZipParser 正常解析备份', async () => {
1197
+ await withTmp(async (dir) => {
1198
+ const homeDir = path.join(dir, 'home');
1199
+ const ctx = new MockHostContext(homeDir);
1200
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 1, secrets: [] });
1201
+ const adapters: ConfigAdapter[] = [new MiniSettingsAdapter()];
1202
+ const zipPath = path.join(dir, 'x.zip');
1203
+ await new Exporter({ ctx, adapters, now: () => new Date() }).export({ includeSecrets: false, outPath: zipPath });
1204
+
1205
+ const importer = new Importer({
1206
+ ctx,
1207
+ adapters,
1208
+ snapshotStore: new MemSnapshotStore(),
1209
+ parseZipOverride: createHardenedZipParser(), // 注入强化 ZIP 安全解析
1210
+ });
1211
+ const analysis = await importer.analyzeImport(zipPath);
1212
+ assert.equal(analysis.valid, true);
1213
+ assert.deepEqual(analysis.sectionsInZip, ['settings']);
1214
+ });
1215
+ });