@oj-module/cli 0.2.2 → 0.2.5

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 (231) hide show
  1. package/devkit/README.md +14 -3
  2. package/devkit/SKILL.md +16 -4
  3. package/devkit/framework-dev.md +8 -6
  4. package/devkit/manual.md +243 -23
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +143 -8
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/args.ts +67 -0
  10. package/src/build.ts +11 -3
  11. package/src/bundle.ts +197 -0
  12. package/src/contract/__tests__/handler-scan.test.ts +82 -0
  13. package/src/contract/check.ts +2 -1
  14. package/src/contract/handler-scan.ts +32 -0
  15. package/src/deterministic-tar.ts +120 -0
  16. package/src/dev.ts +4 -2
  17. package/src/enable.ts +28 -0
  18. package/src/index.ts +63 -2
  19. package/src/info.ts +3 -2
  20. package/src/init.ts +147 -14
  21. package/src/install.ts +268 -0
  22. package/src/keygen.ts +88 -0
  23. package/src/layout.ts +31 -0
  24. package/src/lifecycle/__tests__/authoring.test.ts +109 -0
  25. package/src/lifecycle/__tests__/bundle.test.ts +150 -0
  26. package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
  27. package/src/lifecycle/__tests__/fixtures/README.md +59 -0
  28. package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
  29. package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
  30. package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
  31. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
  32. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
  33. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
  36. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
  37. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
  38. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
  39. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
  40. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
  41. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
  42. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
  43. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
  44. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
  45. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
  46. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
  47. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
  48. package/src/lifecycle/__tests__/helpers.ts +78 -0
  49. package/src/lifecycle/__tests__/install.test.ts +514 -0
  50. package/src/lifecycle/__tests__/keygen.test.ts +61 -0
  51. package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
  52. package/src/lifecycle/__tests__/publish.test.ts +80 -0
  53. package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
  54. package/src/lifecycle/__tests__/sign.test.ts +131 -0
  55. package/src/lifecycle/__tests__/smoke.test.ts +61 -0
  56. package/src/lifecycle/__tests__/trust.test.ts +177 -0
  57. package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
  58. package/src/lifecycle/keygen.ts +40 -0
  59. package/src/lifecycle/sign.ts +74 -0
  60. package/src/manifest-contract.ts +696 -0
  61. package/src/oj-cert.ts +19 -0
  62. package/src/oj-cmd.ts +3 -3
  63. package/src/preview.ts +2 -2
  64. package/src/publish.ts +150 -0
  65. package/src/sign.ts +128 -0
  66. package/src/site.ts +146 -0
  67. package/src/trust.ts +116 -0
  68. package/src/uninstall.ts +29 -0
  69. package/src/usage.ts +35 -1
  70. package/templates/README.md +1 -1
  71. package/templates/api/config.yaml +47 -0
  72. package/templates/api/seed.mysql.sql +1 -1
  73. package/templates/api/seed.pg.sql +1 -1
  74. package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
  75. package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
  76. package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
  77. package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
  78. package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
  79. package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
  80. package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
  81. package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
  82. package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
  83. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
  84. package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
  85. package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
  86. package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
  87. package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
  88. package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
  89. package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
  90. package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
  91. package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
  92. package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
  93. package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
  94. package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
  95. package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
  96. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
  97. package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
  98. package/templates/api/src/_platform/_shared/service/access.ts +2 -2
  99. package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
  100. package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
  101. package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
  102. package/templates/api/src/_platform/manifest.yaml +1 -0
  103. package/templates/api/src/_platform/market/api.ts +883 -0
  104. package/templates/api/src/_platform/market/bootstrap.ts +39 -0
  105. package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
  106. package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
  107. package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
  108. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
  109. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
  110. package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
  111. package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
  112. package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
  113. package/templates/api/src/_platform/schema.yaml +21 -4
  114. package/templates/api/src/_platform/seed.sql +2 -2
  115. package/templates/api/src/_platform/solutions/api.ts +212 -0
  116. package/templates/api/src/demo/manifest.yaml +5 -0
  117. package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
  118. package/templates/api/src/demo/schema.yaml +11 -0
  119. package/templates/api/src/demo/seed.sql +5 -0
  120. package/templates/api/src/demo/todos/api.ts +59 -0
  121. package/templates/api/src/home/line/api.ts +57 -0
  122. package/templates/api/src/home/manifest.yaml +6 -0
  123. package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
  124. package/templates/api/src/home/pie/api.ts +40 -0
  125. package/templates/api/src/home/schema.yaml +14 -0
  126. package/templates/api/src/home/seed.sql +70 -0
  127. package/templates/api/src/system/oplog/api.ts +44 -2
  128. package/templates/api/tests/fixture.ts +16 -0
  129. package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
  130. package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
  131. package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
  132. package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
  133. package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
  134. package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
  135. package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
  136. package/templates/api/tests/lifecycle/register.spec.ts +194 -0
  137. package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
  138. package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
  139. package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
  140. package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
  141. package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
  142. package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
  143. package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
  144. package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
  145. package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
  146. package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
  147. package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
  148. package/templates/api/tests/rbac-settings.spec.ts +8 -0
  149. package/templates/init.include.yaml +56 -0
  150. package/templates/openapi.json +527 -0
  151. package/templates/package.json +52 -0
  152. package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
  153. package/templates/web/src/_platform/__client/api.ts +335 -0
  154. package/templates/web/src/about/entry.ts +42 -0
  155. package/templates/web/src/about/locales/en-US.json +16 -0
  156. package/templates/web/src/about/locales/zh-CN.json +16 -0
  157. package/templates/web/src/about/pages/constants.ts +20 -0
  158. package/templates/web/src/about/pages/index.tsx +135 -0
  159. package/templates/web/src/access/entry.ts +74 -0
  160. package/templates/web/src/access/locales/en-US.json +46 -0
  161. package/templates/web/src/access/locales/zh-CN.json +46 -0
  162. package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
  163. package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
  164. package/templates/web/src/access/pages/button-control/index.tsx +181 -0
  165. package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
  166. package/templates/web/src/access/pages/page-control/index.tsx +142 -0
  167. package/templates/web/src/demo/__client/api.schemas.ts +20 -0
  168. package/templates/web/src/demo/__client/api.ts +89 -0
  169. package/templates/web/src/exception/entry.ts +80 -0
  170. package/templates/web/src/exception/locales/en-US.json +13 -0
  171. package/templates/web/src/exception/locales/zh-CN.json +13 -0
  172. package/templates/web/src/exception/pages/403/index.tsx +30 -0
  173. package/templates/web/src/exception/pages/404/index.tsx +30 -0
  174. package/templates/web/src/exception/pages/500/index.tsx +30 -0
  175. package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
  176. package/templates/web/src/home/__client/api.schemas.ts +22 -0
  177. package/templates/web/src/home/__client/api.ts +113 -0
  178. package/templates/web/src/login/locales/en-US.json +6 -0
  179. package/templates/web/src/login/locales/zh-CN.json +6 -0
  180. package/templates/web/src/login/pages/login.tsx +67 -18
  181. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
  182. package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
  183. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
  184. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
  185. package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
  186. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
  187. package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
  188. package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
  189. package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
  190. package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
  191. package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
  192. package/templates/web/src/oj-market/api/client.ts +135 -0
  193. package/templates/web/src/oj-market/api/context.ts +11 -0
  194. package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
  195. package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
  196. package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
  197. package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
  198. package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
  199. package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
  200. package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
  201. package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
  202. package/templates/web/src/oj-market/entry.ts +27 -0
  203. package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
  204. package/templates/web/src/oj-market/locales/en-US.json +100 -0
  205. package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
  206. package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
  207. package/templates/web/src/oj-market/types.ts +84 -0
  208. package/templates/web/src/oj-market/utils/poll.ts +36 -0
  209. package/templates/web/src/oj-market/utils/semver.ts +14 -0
  210. package/templates/web/src/outside/entry.ts +80 -0
  211. package/templates/web/src/outside/locales/en-US.json +10 -0
  212. package/templates/web/src/outside/locales/zh-CN.json +10 -0
  213. package/templates/web/src/personal-center/entry.ts +1 -10
  214. package/templates/web/src/personal-center/locales/en-US.json +1 -5
  215. package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
  216. package/templates/web/src/route-nest/entry.ts +73 -0
  217. package/templates/web/src/route-nest/locales/en-US.json +9 -0
  218. package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
  219. package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
  220. package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
  221. package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
  222. package/templates/web/src/system/__client/api.schemas.ts +12 -0
  223. package/templates/web/src/system/__client/api.ts +25 -1
  224. package/templates/web/src/system/pages/oplog/index.tsx +1 -1
  225. package/templates/web/src/system/pages/roles/index.tsx +1 -0
  226. package/templates/web/src/system/pages/settings/index.tsx +1 -1
  227. package/templates/web/src/system/pages/users/index.tsx +1 -1
  228. package/templates/web.config.ts +32 -40
  229. package/vendor/host-versions.json +3 -3
  230. package/templates/pnpm-workspace.yaml +0 -14
  231. package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
@@ -0,0 +1,623 @@
1
+ // BDD:下载/校验/解压三步(P1-6,S1-2 裁决范式)。
2
+ // download = fetch → fs.writeFile(落 .staging/<archive.file>,S1-2.3);verify = sha256 fail-closed
3
+ // 比对(C2-29;P1 无签名阶段 CDN 与安装器同信任域,仅防传输损坏不防投毒——设计 §9 补充①);
4
+ // extract = list→validate→extract 三步(R1 zip-slip 防护):-tvzf listing(消费方铁律①)trim 尾部
5
+ // 空行(铁律②)后过 validateTarListing,任一恶意成员全拒则零解压零落盘。
6
+ // 命令构造为纯函数,argv 逐字断言(与平台 exec 白名单逐字对齐,S1-2 基座);
7
+ // exec/fetch/sha256 面经 ctx 注入 stub,不真跑网络;合法夹具集成用例的 exec stub 落到真实系统 tar
8
+ // (与生产同路径:isolate 不内嵌解压,exec.run 系统 tar)。
9
+ import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
10
+ import type { StateStore } from "../../src/_platform/_lifecycle/state";
11
+ import type { DownloadContext } from "../../src/_platform/_lifecycle/steps/download";
12
+ import type { ExecFace, ExtractContext } from "../../src/_platform/_lifecycle/steps/extract";
13
+ import type { VerifyContext } from "../../src/_platform/_lifecycle/steps/verify";
14
+ import { execFile as execFileCb } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import * as fsp from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { promisify } from "node:util";
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+ import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../../src/lifecycle/__tests__/helpers";
22
+ import { archiveFileOf, download } from "../../src/_platform/_lifecycle/steps/download";
23
+ import { buildExtractCmd, buildListCmd, checkModuleManifest, extract } from "../../src/_platform/_lifecycle/steps/extract";
24
+ import { verify } from "../../src/_platform/_lifecycle/steps/verify";
25
+ import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
26
+
27
+ const execFileP = promisify(execFileCb);
28
+ const g = globalThis as any;
29
+
30
+ const FILE = "demo-solution-0.1.0.tgz";
31
+ const BYTES = new TextEncoder().encode("demo-archive-bytes\n");
32
+ const shaOf = (d: Uint8Array) => createHash("sha256").update(d).digest("hex");
33
+
34
+ /** 共享测试 key(进程内稳定;信任库播种 + 夹具签名同一把——无私钥入仓,P2-0 口径) */
35
+ const TEST_KEY = defaultTestKey();
36
+ /** 未知发布方 key(TOFU 场景;不同 key_id) */
37
+ const STRANGER = signedArchiveFor(BYTES, "demo-solution", "0.1.0", { keyPair: TEST_KEY, keyId: "stranger.pub" });
38
+ const STRANGER_KEY = { key_id: "stranger.pub", public_key: STRANGER.publicKeyHex };
39
+
40
+ function nodeFsFace(): FsFace {
41
+ return {
42
+ writeFile: (path, data) => fsp.writeFile(path, data),
43
+ readFile: path => fsp.readFile(path),
44
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
45
+ remove: (path, opts) => fsp.rm(path, opts),
46
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
47
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
48
+ readdir: path => fsp.readdir(path),
49
+ };
50
+ }
51
+
52
+ function stateStore(): StateStore {
53
+ return { read: () => ({ solutions: {} }), write: async () => {} };
54
+ }
55
+
56
+ function manifest(archive: Record<string, unknown> = { file: FILE, sha256: shaOf(BYTES), size: BYTES.byteLength, signature: signedArchiveFor(BYTES, "demo-solution", "0.1.0").sig }): any {
57
+ return { name: "demo-solution", version: "0.1.0", archive };
58
+ }
59
+
60
+ let root: string;
61
+ let staging: string;
62
+ let trustPath: string;
63
+
64
+ beforeEach(async () => {
65
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-steps-fs-"));
66
+ staging = join(root, "modules", ".staging");
67
+ trustPath = join(root, "modules", ".solutions", "trusted-keys.yaml");
68
+ g.fs = nodeFsFace();
69
+ // 播种信任库(走真实 store:序列化格式零重复)。key_id 与 signedArchiveFor 缺省 keyId 一致
70
+ await createTrustStore(trustPath, { audit: () => {}, fingerprintOf: pk => shaOf(hexToBytes(pk)) }).add("test.publisher", TEST_KEY.publicKeyHex);
71
+ });
72
+
73
+ /** hex → bytes(指纹面注入用) */
74
+ function hexToBytes(hex: string): Uint8Array {
75
+ const out = new Uint8Array(hex.length / 2);
76
+ for (let i = 0; i < out.length; i++)
77
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
78
+ return out;
79
+ }
80
+
81
+ afterEach(async () => {
82
+ g.fs = undefined;
83
+ await fsp.rm(root, { recursive: true, force: true });
84
+ });
85
+
86
+ /** 记录 argv 的 exec stub(默认恒成功;handler 可模拟失败) */
87
+ function recordingExec(handler?: (argv: string[]) => Promise<{ code: number, stdout: string, stderr: string }>): ExecFace & { calls: string[][] } {
88
+ const calls: string[][] = [];
89
+ return {
90
+ calls,
91
+ run: async (argv) => {
92
+ calls.push([...argv]);
93
+ return handler
94
+ ? handler(argv)
95
+ : { code: 0, stdout: "", stderr: "" };
96
+ },
97
+ };
98
+ }
99
+
100
+ /** 落到真实系统 tar 的 exec stub(生产同路径) */
101
+ function realTarExec(): ExecFace & { calls: string[][] } {
102
+ return recordingExec(async (argv) => {
103
+ try {
104
+ const { stdout, stderr } = await execFileP(argv[0]!, argv.slice(1));
105
+ return { code: 0, stdout, stderr };
106
+ }
107
+ catch (e: any) {
108
+ return { code: typeof e.code === "number" ? e.code : 1, stdout: e.stdout ?? "", stderr: e.stderr ?? String(e) };
109
+ }
110
+ });
111
+ }
112
+
113
+ function dlCtx(overrides: Partial<DownloadContext> = {}): DownloadContext {
114
+ return {
115
+ manifest: manifest(),
116
+ name: "demo-solution",
117
+ version: "0.1.0",
118
+ state: stateStore(),
119
+ archiveUrl: `https://cdn.example.com/${FILE}`,
120
+ staging,
121
+ fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => BYTES.slice().buffer as ArrayBuffer }),
122
+ ...overrides,
123
+ };
124
+ }
125
+
126
+ function vfCtx(overrides: Partial<VerifyContext> = {}): VerifyContext {
127
+ return {
128
+ manifest: manifest(),
129
+ name: "demo-solution",
130
+ version: "0.1.0",
131
+ state: stateStore(),
132
+ staging,
133
+ sha256Hex: shaOf,
134
+ trustPath,
135
+ verifyEd25519: nodeVerifyEd25519,
136
+ ...overrides,
137
+ };
138
+ }
139
+
140
+ function exCtx(overrides: Partial<ExtractContext> = {}): ExtractContext {
141
+ return {
142
+ manifest: manifest(),
143
+ name: "demo-solution",
144
+ version: "0.1.0",
145
+ state: stateStore(),
146
+ staging,
147
+ exec: recordingExec(),
148
+ ...overrides,
149
+ };
150
+ }
151
+
152
+ describe("命令构造纯函数(无 exec;argv 逐字断言,exec 白名单对齐 S1-2)", () => {
153
+ it("buildListCmd 逐字:[tar, -tvzf, file]", () => {
154
+ expect(buildListCmd("/m/.staging/demo-solution-0.1.0.tgz")).toEqual([
155
+ "tar",
156
+ "-tvzf",
157
+ "/m/.staging/demo-solution-0.1.0.tgz",
158
+ ]);
159
+ });
160
+
161
+ it("列表命令必须 verbose 形态(铁律①:非 verbose 裸名撞 l/h 误拒,禁退化 -tzf)", () => {
162
+ const argv = buildListCmd("x.tgz");
163
+ expect(argv).toContain("-tvzf");
164
+ expect(argv).not.toContain("-tzf");
165
+ });
166
+
167
+ it("buildExtractCmd 逐字:[tar, -xzf, file, -C, staging](-C 限定目标目录)", () => {
168
+ expect(buildExtractCmd("/m/.staging/x.tgz", "/m/.staging")).toEqual([
169
+ "tar",
170
+ "-xzf",
171
+ "/m/.staging/x.tgz",
172
+ "-C",
173
+ "/m/.staging",
174
+ ]);
175
+ });
176
+
177
+ it("archiveFileOf:合法 Form B 提取 file(archive 段随行返回供 verify 取 sha256)", () => {
178
+ const r = archiveFileOf(manifest());
179
+ expect(r.ok).toBe(true);
180
+ expect(r.ok && r.file).toBe(FILE);
181
+ });
182
+
183
+ it("archiveFileOf:archive 段缺失拒(C2-29)", () => {
184
+ const r = archiveFileOf({ name: "demo-solution" });
185
+ expect(r.ok).toBe(false);
186
+ expect(!r.ok && r.message).toContain("C2-29");
187
+ });
188
+
189
+ it("archiveFileOf:路径穿越/分隔符形态拒(C2-29 安全形态,信任边界)", () => {
190
+ for (const file of ["../evil.tgz", "a/b.tgz", ".hidden.tgz", "", ".."]) {
191
+ const r = archiveFileOf(manifest({ file }));
192
+ expect(r.ok).toBe(false);
193
+ expect(!r.ok && r.message).toContain("C2-29");
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("download(stage=downloading,S1-2.3 fetch→fs.writeFile)", () => {
199
+ it("成功:落盘 .staging/<archive.file>,staging 不存在自动创建,字节完整", async () => {
200
+ const r = await download(dlCtx());
201
+ expect(r.stage).toBe("downloading");
202
+ expect(r.ok).toBe(true);
203
+ expect(new Uint8Array(await fsp.readFile(join(staging, FILE)))).toEqual(BYTES);
204
+ });
205
+
206
+ it("hTTP 非 2xx:ok=false retryable=true,message 带状态码与附录 A 引用,零落盘", async () => {
207
+ const r = await download(dlCtx({ fetch: async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }) }));
208
+ expect(r.ok).toBe(false);
209
+ expect(r.retryable).toBe(true);
210
+ expect(r.message).toContain("下载失败");
211
+ expect(r.message).toContain("404");
212
+ expect(r.message).toContain("附录 A");
213
+ await expect(fsp.readdir(staging)).rejects.toThrow();
214
+ });
215
+
216
+ it("fetch 抛错(网络异常):ok=false retryable=true", async () => {
217
+ const r = await download(dlCtx({ fetch: async () => {
218
+ throw new Error("ECONNREFUSED");
219
+ } }));
220
+ expect(r.ok).toBe(false);
221
+ expect(r.retryable).toBe(true);
222
+ expect(r.message).toContain("下载失败");
223
+ });
224
+
225
+ it("p2-11 索引投毒加固:archive.size 与实收字节不符 → 拒(retryable=false,size 立即快筛)", async () => {
226
+ const r = await download(dlCtx({ manifest: manifest({ file: FILE, sha256: shaOf(BYTES), size: BYTES.byteLength + 1 }) }));
227
+ expect(r.ok).toBe(false);
228
+ expect(r.retryable).toBe(false);
229
+ expect(r.message).toContain("archive.size");
230
+ expect(r.message).toContain(String(BYTES.byteLength));
231
+ });
232
+
233
+ it("p2-11:archive.size 缺省(Form A 未填口径)跳过 size 快筛,sha256 兜底仍在 verify 段", async () => {
234
+ const r = await download(dlCtx({ manifest: manifest({ file: FILE, sha256: shaOf(BYTES) }) }));
235
+ expect(r.ok).toBe(true);
236
+ });
237
+
238
+ it("p2-11:archive.file 非逐字 <name>-<version>.tgz(异版本/异名/伪扩展)→ 拒(穿越形态由 archiveFileOf 通用形拒)", async () => {
239
+ for (const file of ["demo-solution-9.9.9.tgz", "other-0.1.0.tgz", "demo-solution-0.1.0.tgz.bak"]) {
240
+ let calls = 0;
241
+ const r = await download(dlCtx({
242
+ manifest: manifest({ file, sha256: shaOf(BYTES), size: BYTES.byteLength }),
243
+ fetch: async () => {
244
+ calls++;
245
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
246
+ },
247
+ }));
248
+ expect(r.ok, file).toBe(false);
249
+ expect(r.retryable, file).toBe(false);
250
+ expect(r.message, file).toContain("逐字");
251
+ expect(calls, file).toBe(0);
252
+ }
253
+ });
254
+
255
+ it("archive.file 缺失(Form A 占位无身份):ok=false retryable=false 引 C2-29,fetch 零调用", async () => {
256
+ let calls = 0;
257
+ const r = await download(dlCtx({
258
+ manifest: manifest({}),
259
+ fetch: async () => {
260
+ calls++;
261
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
262
+ },
263
+ }));
264
+ expect(r.ok).toBe(false);
265
+ expect(r.retryable).toBe(false);
266
+ expect(r.message).toContain("C2-29");
267
+ expect(calls).toBe(0);
268
+ });
269
+ });
270
+
271
+ describe("verify(stage=verifying,C2-29 sha256 fail-closed + P2-3 六码验签)", () => {
272
+ async function seed(): Promise<void> {
273
+ await fsp.mkdir(staging, { recursive: true });
274
+ await fsp.writeFile(join(staging, FILE), BYTES);
275
+ }
276
+
277
+ it("成功:sha256 与索引一致 + 已信任 key 验签通过 → ok=true", async () => {
278
+ await seed();
279
+ const r = await verify(vfCtx());
280
+ expect(r.stage).toBe("verifying");
281
+ expect(r.ok).toBe(true);
282
+ expect(r.message).toContain("验签通过");
283
+ });
284
+
285
+ it("摘要不符:ok=false retryable=true,message 引 C2-29 且含实得摘要", async () => {
286
+ await seed();
287
+ const r = await verify(vfCtx({ manifest: manifest({ file: FILE, sha256: "0".repeat(64), size: 1, signature: signedArchiveFor(BYTES, "demo-solution", "0.1.0").sig }) }));
288
+ expect(r.ok).toBe(false);
289
+ expect(r.retryable).toBe(true);
290
+ expect(r.message).toContain("C2-29");
291
+ expect(r.message).toContain(shaOf(BYTES));
292
+ });
293
+
294
+ it("archive.sha256 缺失:fail-closed 拒(retryable=false,Form A 占位不可用于安装校验)", async () => {
295
+ await seed();
296
+ const r = await verify(vfCtx({ manifest: manifest({ file: FILE }) }));
297
+ expect(r.ok).toBe(false);
298
+ expect(r.retryable).toBe(false);
299
+ expect(r.message).toContain("C2-29");
300
+ expect(r.message).toContain("fail-closed");
301
+ });
302
+
303
+ it("盘上文件缺失:ok=false retryable=true(附录 A verifying:请重试下载)", async () => {
304
+ const r = await verify(vfCtx());
305
+ expect(r.ok).toBe(false);
306
+ expect(r.retryable).toBe(true);
307
+ expect(r.message).toContain("附录 A");
308
+ });
309
+
310
+ it("摘要能力不可用(ctx 未注入且运行时全局无 sha256Hex):fail-closed 不静默跳过", async () => {
311
+ await seed();
312
+ expect((g.crypto ?? {}).sha256Hex).toBeUndefined();
313
+ const { sha256Hex: _drop, ...without } = vfCtx();
314
+ const r = await verify(without as VerifyContext);
315
+ expect(r.ok).toBe(false);
316
+ expect(r.retryable).toBe(false);
317
+ expect(r.message).toContain("fail-closed");
318
+ });
319
+
320
+ // ── P2-3 六码(契约附录 A.2;retryable 恒 false、零解压零落盘)────────────────────
321
+
322
+ it("六码数据表:sig_missing/sig_alg/sig_key/tampered/sig_format 逐码一例", async () => {
323
+ await seed();
324
+ const good = signedArchiveFor(BYTES, "demo-solution", "0.1.0").sig;
325
+
326
+ // sig_missing:无 signature
327
+ const noSig = await verify(vfCtx({ manifest: manifest({ file: FILE, sha256: shaOf(BYTES), size: BYTES.byteLength }) }));
328
+ expect(noSig).toMatchObject({ ok: false, code: "sig_missing", retryable: false });
329
+
330
+ // sig_alg:小写 ed25519 / 未知枚举
331
+ const badAlg = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, alg: "ed25519" } }) }));
332
+ expect(badAlg).toMatchObject({ ok: false, code: "sig_alg", retryable: false });
333
+
334
+ // sig_key:key_id 命名空间违规(裸通用名)
335
+ const badNs = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, key_id: "default" } }) }));
336
+ expect(badNs).toMatchObject({ ok: false, code: "sig_key", retryable: false });
337
+
338
+ // sig_format:public_key 非 64hex / value 含 -(base64url 特征字符)/ canon 非 v1
339
+ const badKey = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, public_key: "abc" } }) }));
340
+ expect(badKey).toMatchObject({ ok: false, code: "sig_format" });
341
+ const b64url = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, value: `${good.value.slice(0, 10)}-${good.value.slice(11)}` } }) }));
342
+ expect(b64url).toMatchObject({ ok: false, code: "sig_format" });
343
+ const badCanon = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, canon: "v2" } }) }));
344
+ expect(badCanon).toMatchObject({ ok: false, code: "sig_format" });
345
+
346
+ // tampered:value 首字符翻转(仍是合法标准 base64)
347
+ const tamperedValue = (good.value[0] === "A" ? "B" : "A") + good.value.slice(1);
348
+ const tampered = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, value: tamperedValue } }) }));
349
+ expect(tampered).toMatchObject({ ok: false, code: "tampered", retryable: false });
350
+ });
351
+
352
+ it("sig_key:已信任 key_id 但包内公钥与库绑定不一致(首次绑定不可改族)", async () => {
353
+ await seed();
354
+ // 另一把私钥冒用已信任 key_id(trusted key_id + 异公钥 = 绑定不一致)
355
+ const { signedArchiveFor: signFor } = await import("../../../../src/lifecycle/__tests__/helpers");
356
+ const { generateKeyPair } = await import("../../../../src/lifecycle/keygen");
357
+ const impostor = signFor(BYTES, "demo-solution", "0.1.0", { keyPair: generateKeyPair(), keyId: "test.publisher" });
358
+ const r = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: impostor.sig }) }));
359
+ expect(r).toMatchObject({ ok: false, code: "sig_key" });
360
+ expect(r.message).toContain("首次绑定不可改");
361
+ });
362
+
363
+ it("unknown_key → needsTrust:staging 不清场语义由编排层保证,此处返回待确认 key 信息", async () => {
364
+ await seed();
365
+ const r = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: STRANGER.sig }) }));
366
+ expect(r).toMatchObject({ ok: false, code: "unknown_key", needsTrust: true, retryable: false });
367
+ expect(r.trust).toMatchObject({ key_id: "stranger.pub", public_key: STRANGER.publicKeyHex });
368
+ expect(r.trust?.fingerprint).toMatch(/^[0-9a-f]{64}$/);
369
+ // 零解压:staging 仍只有原始 tgz(本步不触 extract)
370
+ expect(await fsp.readdir(staging)).toEqual([FILE]);
371
+ });
372
+
373
+ it("双层语义分码:验签面抛错 → sig_format(上游 verifyEd25519: 前缀);返回 false → tampered", async () => {
374
+ await seed();
375
+ const throwing = await verify(vfCtx({
376
+ verifyEd25519: () => { throw new Error("verifyEd25519: signature not base64"); },
377
+ }));
378
+ expect(throwing).toMatchObject({ ok: false, code: "sig_format" });
379
+ expect(throwing.message).toContain("verifyEd25519: ");
380
+ const falseFace = await verify(vfCtx({ verifyEd25519: () => false }));
381
+ expect(falseFace).toMatchObject({ ok: false, code: "tampered" });
382
+ });
383
+
384
+ it("原语/信任库不可得:验签面缺失或 trustPath 缺失 → fail-closed 拒装(无静默跳过)", async () => {
385
+ await seed();
386
+ const { verifyEd25519: _drop, ...withoutFace } = vfCtx();
387
+ expect((g.crypto ?? {}).verifyEd25519).toBeUndefined();
388
+ const r1 = await verify(withoutFace as VerifyContext);
389
+ expect(r1).toMatchObject({ ok: false, code: "sig_format" });
390
+ expect(r1.message).toContain("fail-closed");
391
+ const { trustPath: _drop2, ...withoutTrust } = vfCtx();
392
+ const r2 = await verify(withoutTrust as VerifyContext);
393
+ expect(r2).toMatchObject({ ok: false, code: "sig_missing" });
394
+ expect(r2.message).toContain("信任库");
395
+ });
396
+
397
+ it("tOFU 全链(store 层):未知 key 确认入信任库后同包重试即过", async () => {
398
+ await seed();
399
+ const manifestUnknown = manifest({ ...manifest().archive, signature: STRANGER.sig });
400
+ expect((await verify(vfCtx({ manifest: manifestUnknown }))).needsTrust).toBe(true);
401
+ // 确认(端点同路径:store.add)
402
+ await createTrustStore(trustPath, { audit: () => {} }).add(STRANGER_KEY.key_id, STRANGER_KEY.public_key);
403
+ const r2 = await verify(vfCtx({ manifest: manifestUnknown }));
404
+ expect(r2.ok).toBe(true);
405
+ expect(r2.message).toContain("stranger.pub");
406
+ });
407
+ });
408
+
409
+ describe("extract(stage=extracting,R1 list→validate→extract 三步)", () => {
410
+ it("合法夹具集成(真实系统 tar):§2.9 单根包解压后归平为扁平 staging,两次 exec argv 逐字对齐命令构造", async () => {
411
+ const pkg = join(root, "pkg", "demo-solution-0.1.0");
412
+ await fsp.mkdir(join(pkg, "src"), { recursive: true });
413
+ await fsp.writeFile(join(pkg, "manifest.yaml"), "name: demo-solution\n");
414
+ await fsp.writeFile(join(pkg, "src", "entry.js"), "export default {};\n");
415
+ await fsp.mkdir(staging, { recursive: true });
416
+ const tgz = join(staging, FILE);
417
+ await execFileP("tar", ["-czf", tgz, "-C", join(root, "pkg"), "demo-solution-0.1.0"]);
418
+
419
+ const exec = realTarExec();
420
+ const r = await extract(exCtx({ exec }));
421
+ expect(r.stage).toBe("extracting");
422
+ expect(r.ok).toBe(true);
423
+ // 单根归平(附录 B-10):包根内容上提一层,安装器全链消费扁平 staging
424
+ expect(await fsp.readFile(join(staging, "manifest.yaml"), "utf8")).toBe("name: demo-solution\n");
425
+ expect(await fsp.readFile(join(staging, "src", "entry.js"), "utf8")).toBe("export default {};\n");
426
+ expect(await fsp.readdir(staging)).toEqual(["demo-solution-0.1.0.tgz", "manifest.yaml", "src"].sort());
427
+ expect(exec.calls[0]).toEqual(buildListCmd(tgz));
428
+ expect(exec.calls[1]).toEqual(buildExtractCmd(tgz, staging));
429
+ });
430
+
431
+ it("flat 布局包(manifest.yaml 在包根):归平为空操作,layout 原样", async () => {
432
+ const flat = join(root, "flat-pkg");
433
+ await fsp.mkdir(flat, { recursive: true });
434
+ await fsp.writeFile(join(flat, "manifest.yaml"), "name: demo-solution\n");
435
+ await fsp.mkdir(staging, { recursive: true });
436
+ const tgz = join(staging, FILE);
437
+ await execFileP("tar", ["-czf", tgz, "-C", flat, "manifest.yaml"]);
438
+
439
+ const exec = realTarExec();
440
+ const r = await extract(exCtx({ exec }));
441
+ expect(r.ok).toBe(true);
442
+ expect(await fsp.readFile(join(staging, "manifest.yaml"), "utf8")).toBe("name: demo-solution\n");
443
+ expect(await fsp.readdir(staging)).toEqual(["demo-solution-0.1.0.tgz", "manifest.yaml"].sort());
444
+ });
445
+
446
+ it("恶意 listing(symlink + `..` 成员):validate 段全拒,零解压零落盘(exec 仅 1 次列表调用)", async () => {
447
+ const exec = recordingExec(async () => ({
448
+ code: 0,
449
+ stdout: [
450
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 demo-solution-0.1.0/manifest.yaml",
451
+ "lrwxr-xr-x 0 ever staff 0 10 17 12:00 demo-solution-0.1.0/link -> /etc/passwd",
452
+ "-rw-r--r-- 0 ever staff 7 10 17 12:00 ../evil",
453
+ ].join("\n"),
454
+ stderr: "",
455
+ }));
456
+ await fsp.mkdir(staging, { recursive: true });
457
+ const r = await extract(exCtx({ exec }));
458
+ expect(r.ok).toBe(false);
459
+ expect(r.retryable).toBe(true);
460
+ expect(r.message).toContain("R1");
461
+ expect(r.message).toContain("零解压");
462
+ expect(r.message).toContain("demo-solution-0.1.0/link");
463
+ expect(exec.calls.length).toBe(1);
464
+ expect(exec.calls[0]).toEqual(buildListCmd(join(staging, FILE)));
465
+ expect(await fsp.readdir(staging)).toEqual([]);
466
+ });
467
+
468
+ it("列表命令失败(exit≠0):ok=false retryable=true,stderr 入 message", async () => {
469
+ const exec = recordingExec(async () => ({ code: 2, stdout: "", stderr: "tar: unrecognized format" }));
470
+ const r = await extract(exCtx({ exec }));
471
+ expect(r.ok).toBe(false);
472
+ expect(r.retryable).toBe(true);
473
+ expect(r.message).toContain("tar: unrecognized format");
474
+ expect(exec.calls.length).toBe(1);
475
+ });
476
+
477
+ it("解压命令失败(合法 listing 后 exit≠0):ok=false retryable=true", async () => {
478
+ const clean = "-rw-r--r-- 0 ever staff 7 10 17 12:00 demo-solution-0.1.0/manifest.yaml";
479
+ const exec = recordingExec(async argv => argv[1] === "-xzf"
480
+ ? { code: 1, stdout: "", stderr: "tar: cannot extract" }
481
+ : { code: 0, stdout: clean, stderr: "" });
482
+ const r = await extract(exCtx({ exec }));
483
+ expect(r.ok).toBe(false);
484
+ expect(r.retryable).toBe(true);
485
+ expect(r.message).toContain("解压失败");
486
+ expect(exec.calls.length).toBe(2);
487
+ });
488
+
489
+ it("铁律②:尾部多空行的 listing 先 trim 再校验,合法成员照常放行", async () => {
490
+ const exec = recordingExec(async () => ({
491
+ code: 0,
492
+ stdout: "-rw-r--r-- 0 ever staff 7 10 17 12:00 demo-solution-0.1.0/manifest.yaml\n\n\n",
493
+ stderr: "",
494
+ }));
495
+ const r = await extract(exCtx({ exec }));
496
+ expect(r.ok).toBe(true);
497
+ expect(exec.calls.length).toBe(2);
498
+ });
499
+
500
+ it("空 listing:退化行=空成员名,fail-closed 拒绝", async () => {
501
+ const exec = recordingExec(async () => ({ code: 0, stdout: "", stderr: "" }));
502
+ const r = await extract(exCtx({ exec }));
503
+ expect(r.ok).toBe(false);
504
+ expect(r.message).toContain("R1");
505
+ expect(exec.calls.length).toBe(1);
506
+ });
507
+ });
508
+
509
+ // B-11/C2-31 安装期复核:包内 api/<name>-<version>/manifest.yaml 是 oj 原生清单(desc 必填,
510
+ // serve 装配 parse_one 消费面),与 C2 Form A 的 summary 字段由构建期映射(ojm build --bundle
511
+ // 写入并核对);安装期只验不写——写等于掩盖上游不一致(映射被篡改的包必须拒装)。
512
+ // 纯函数与 packages/cli 侧 bundle.ts checkModuleManifest 双端 DRY 于夹具(preflight 同纪律)。
513
+ describe("checkModuleManifest(C2-31 安装期复核纯函数:C2 manifest ↔ 包内 oj 原生清单)", () => {
514
+ const c2 = { name: "demo-solution", version: "0.1.0", summary: "演示方案 Demo — émoji 🎉" };
515
+ const native = { name: "demo-solution", desc: "演示方案 Demo — émoji 🎉", version: "0.1.0" };
516
+
517
+ it("合法:desc === summary(多字节逐字相等)、name/version 三方一致、七键之内 → ok", () => {
518
+ expect(checkModuleManifest(c2, native, "demo-solution", "0.1.0")).toEqual({ ok: true });
519
+ });
520
+
521
+ it("summary 缺省(C2 侧无映射真源)→ 拒", () => {
522
+ const r = checkModuleManifest({ name: "demo-solution", version: "0.1.0" }, native, "demo-solution", "0.1.0");
523
+ expect(r.ok).toBe(false);
524
+ expect(!r.ok && r.message).toContain("summary");
525
+ });
526
+
527
+ it("desc 缺失 → 拒(oj Manifest 必填字段,serve 装配即炸)", () => {
528
+ const r = checkModuleManifest(c2, { name: "demo-solution", version: "0.1.0" }, "demo-solution", "0.1.0");
529
+ expect(r.ok).toBe(false);
530
+ expect(!r.ok && r.message).toContain("desc");
531
+ });
532
+
533
+ it("desc 为空白串 → 拒(oj 原生 desc 语义:非空描述)", () => {
534
+ const r = checkModuleManifest(c2, { ...native, desc: " " }, "demo-solution", "0.1.0");
535
+ expect(r.ok).toBe(false);
536
+ });
537
+
538
+ it("desc !== summary → 拒(构建期映射被篡改/上游不一致)", () => {
539
+ const r = checkModuleManifest(c2, { ...native, desc: "另一个描述" }, "demo-solution", "0.1.0");
540
+ expect(r.ok).toBe(false);
541
+ expect(!r.ok && r.message).toContain("desc");
542
+ });
543
+
544
+ it("name 或 version 与包身份不一致 → 拒", () => {
545
+ expect(checkModuleManifest(c2, { ...native, name: "other" }, "demo-solution", "0.1.0").ok).toBe(false);
546
+ expect(checkModuleManifest(c2, { ...native, version: "0.2.0" }, "demo-solution", "0.1.0").ok).toBe(false);
547
+ });
548
+
549
+ it("白名单外键(七键 name/desc/version/config/db/tables/deps 之外)→ 拒(OJ-X 红线)", () => {
550
+ const r = checkModuleManifest(c2, { ...native, evil: "x" }, "demo-solution", "0.1.0");
551
+ expect(r.ok).toBe(false);
552
+ expect(!r.ok && r.message).toContain("evil");
553
+ });
554
+
555
+ it("七键之内可选键(config/db/tables/deps)放行", () => {
556
+ const rich = { ...native, tables: ["note"], deps: { _platform: ">=0.1.0" } };
557
+ expect(checkModuleManifest(c2, rich, "demo-solution", "0.1.0")).toEqual({ ok: true });
558
+ });
559
+
560
+ it("native 非键值对象 → 拒(fail-closed 不猜)", () => {
561
+ for (const bad of [null, "text", 42, ["demo-solution"]]) {
562
+ expect(checkModuleManifest(c2, bad, "demo-solution", "0.1.0").ok).toBe(false);
563
+ }
564
+ });
565
+ });
566
+
567
+ describe("extract C2-31 安装期复核(B-11:api 段声明与包内原生清单互相钉住)", () => {
568
+ beforeEach(() => {
569
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
570
+ });
571
+
572
+ afterEach(() => {
573
+ g.yaml = undefined;
574
+ });
575
+
576
+ /** 组装 §2.9 单根包并 tar 真打包(含 api 模块目录:原生清单 + routes.js + 迁移 SQL) */
577
+ async function seedBundle(opts: { withApi?: boolean, nativeManifest?: unknown } = {}): Promise<string> {
578
+ const pkg = join(root, "pkg", "demo-solution-0.1.0");
579
+ if (opts.withApi !== false) {
580
+ const apiDir = join(pkg, "api", "demo-solution-0.1.0", "migrations");
581
+ await fsp.mkdir(apiDir, { recursive: true });
582
+ const nm = opts.nativeManifest ?? { name: "demo-solution", desc: "演示方案 Demo", version: "0.1.0" };
583
+ await fsp.writeFile(join(pkg, "api", "demo-solution-0.1.0", "manifest.yaml"), JSON.stringify(nm));
584
+ await fsp.writeFile(join(pkg, "api", "demo-solution-0.1.0", "routes.js"), "export {};\n");
585
+ await fsp.writeFile(join(pkg, "api", "demo-solution-0.1.0", "migrations", "0001__init.sql"), "CREATE TABLE note (id INTEGER PRIMARY KEY);\n");
586
+ }
587
+ await fsp.mkdir(join(pkg, "web", "modules", "demo-solution", "0.1.0"), { recursive: true });
588
+ await fsp.writeFile(join(pkg, "manifest.yaml"), JSON.stringify({ name: "demo-solution", version: "0.1.0", summary: "演示方案 Demo", api: { prefix: "/api/demo-solution" } }));
589
+ await fsp.writeFile(join(pkg, "web", "modules", "demo-solution", "0.1.0", "entry.js"), "export {};\n");
590
+ await fsp.mkdir(staging, { recursive: true });
591
+ const tgz = join(staging, FILE);
592
+ await execFileP("tar", ["-czf", tgz, "-C", join(root, "pkg"), "demo-solution-0.1.0"]);
593
+ return tgz;
594
+ }
595
+
596
+ function c2Manifest(): any {
597
+ return { name: "demo-solution", version: "0.1.0", summary: "演示方案 Demo", archive: { file: FILE, sha256: shaOf(BYTES), size: BYTES.byteLength }, api: { prefix: "/api/demo-solution" } };
598
+ }
599
+
600
+ it("api 段声明 + 包内原生清单合法(desc === summary)→ extract ok", async () => {
601
+ await seedBundle();
602
+ const r = await extract(exCtx({ manifest: c2Manifest(), exec: realTarExec() }));
603
+ expect(r.ok).toBe(true);
604
+ // 单根归平后 api 模块目录在 staging 根(migrate svc 物化与 lockCommit rename 的消费面)
605
+ await fsp.access(join(staging, "api", "demo-solution-0.1.0", "routes.js"));
606
+ });
607
+
608
+ it("manifest 声明 api 段而包内 api/<name>-<version>/ 缺失 → C2-31 拒(retryable=false)", async () => {
609
+ await seedBundle({ withApi: false });
610
+ const r = await extract(exCtx({ manifest: c2Manifest(), exec: realTarExec() }));
611
+ expect(r.ok).toBe(false);
612
+ expect(r.retryable).toBe(false);
613
+ expect(r.message).toContain("C2-31");
614
+ });
615
+
616
+ it("包内原生清单 desc !== summary → C2-31 拒(映射被篡改)", async () => {
617
+ await seedBundle({ nativeManifest: { name: "demo-solution", desc: "另一个描述", version: "0.1.0" } });
618
+ const r = await extract(exCtx({ manifest: c2Manifest(), exec: realTarExec() }));
619
+ expect(r.ok).toBe(false);
620
+ expect(r.retryable).toBe(false);
621
+ expect(r.message).toContain("C2-31");
622
+ });
623
+ });