@oj-module/cli 0.2.29 → 0.2.42

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 (117) hide show
  1. package/devkit/manual.md +5 -5
  2. package/devkit/module-dev-and-cdn-playground.md +1 -1
  3. package/package.json +2 -2
  4. package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
  5. package/shell-dist/assets/runtime.js +2 -2
  6. package/shell-dist/index.html +3 -3
  7. package/shell-dist/versions.json +3 -3
  8. package/src/__tests__/cli.test.ts +23 -14
  9. package/src/__tests__/list.test.ts +176 -0
  10. package/src/__tests__/registry-cli.test.ts +522 -0
  11. package/src/__tests__/registry-export.test.ts +319 -0
  12. package/src/__tests__/registry-import.test.ts +336 -0
  13. package/src/__tests__/registry-sign.test.ts +352 -0
  14. package/src/cli-output.ts +81 -0
  15. package/src/cli.ts +298 -39
  16. package/src/config-cmd.ts +100 -0
  17. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  18. package/src/contract/handler-scan.ts +27 -2
  19. package/src/enable.ts +1 -1
  20. package/src/exit-codes.ts +33 -0
  21. package/src/index.ts +5 -9
  22. package/src/install.ts +120 -38
  23. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  28. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  29. package/src/lifecycle/__tests__/helpers.ts +10 -6
  30. package/src/lifecycle/__tests__/install.test.ts +72 -15
  31. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  32. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  33. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  34. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  35. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  36. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  37. package/src/lifecycle/sign.ts +8 -7
  38. package/src/list.ts +146 -0
  39. package/src/manifest-contract.ts +11 -5
  40. package/src/market/__tests__/bootstrap.test.ts +4 -1
  41. package/src/market/__tests__/build-index.test.ts +529 -42
  42. package/src/market/__tests__/fixture.test.ts +17 -1
  43. package/src/market/__tests__/publish.test.ts +8 -7
  44. package/src/market/__tests__/setup.test.ts +4 -4
  45. package/src/market/__tests__/sharding.test.ts +90 -0
  46. package/src/market/__tests__/verify.test.ts +205 -12
  47. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  48. package/src/market/bootstrap.ts +8 -3
  49. package/src/market/build-index.ts +407 -40
  50. package/src/market/cdn-layout.ts +3 -2
  51. package/src/market/command.ts +40 -6
  52. package/src/market/failure-playbook.ts +1 -1
  53. package/src/market/fixture.ts +20 -3
  54. package/src/market/index-builder.ts +120 -47
  55. package/src/market/publish.ts +3 -1
  56. package/src/market/setup.ts +3 -3
  57. package/src/market/sharding.ts +94 -0
  58. package/src/market/trial.ts +1 -1
  59. package/src/market/verify.ts +213 -50
  60. package/src/market/walkthrough.ts +7 -6
  61. package/src/market/yank-ledger.ts +91 -0
  62. package/src/publish.ts +250 -38
  63. package/src/registry-export.ts +405 -0
  64. package/src/registry-import.ts +410 -0
  65. package/src/registry-sidecar.ts +84 -0
  66. package/src/registry-sign.ts +322 -0
  67. package/src/registry-source.ts +192 -0
  68. package/src/sign.ts +13 -10
  69. package/src/site.ts +4 -4
  70. package/src/trust.ts +1 -1
  71. package/src/uninstall.ts +14 -2
  72. package/src/usage.ts +130 -25
  73. package/src/view.ts +94 -0
  74. package/templates/cdn/README.md +35 -5
  75. package/templates/config.yaml +10 -0
  76. package/templates/init.include.yaml +1 -1
  77. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  78. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  79. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  80. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  82. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  83. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  84. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  85. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  86. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  87. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  88. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  89. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  90. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  91. package/templates/src/_platform/api/manifest.yaml +1 -0
  92. package/templates/src/_platform/api/market/api.ts +362 -99
  93. package/templates/src/_platform/api/market/index-source.ts +112 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  96. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  97. package/templates/src/_platform/api/schema.yaml +8 -0
  98. package/templates/src/_platform/api/solutions/api.ts +8 -3
  99. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  100. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  101. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  102. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  103. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  104. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  105. package/templates/src/oj-market/web/types.ts +3 -1
  106. package/templates/src/system/web/entry.ts +5 -2
  107. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  108. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  109. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  110. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  111. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  112. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  113. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  114. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  115. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  116. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  117. package/vendor/host-versions.json +3 -3
@@ -49,6 +49,9 @@ interface RuleCase {
49
49
  const RULES: RuleCase[] = [
50
50
  { rule: "name 白名单(大写拒绝)", clause: "C2-01", keyword: "name", mutate: (m) => { m.name = "Demo"; } },
51
51
  { rule: "name 保留名(market,R9)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "market"; } },
52
+ { rule: "name 保留名(oj,REG-CS7)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "oj"; } },
53
+ { rule: "name 保留名(ojm,REG-CS7)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "ojm"; } },
54
+ { rule: "name 保留名(registry,REG-CS7)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "registry"; } },
52
55
  { rule: "version 禁 +(oj 相容排除,附录 B-1)", clause: "C2-02", keyword: "version", mutate: (m) => { m.version = "1.0.0+build.5"; } },
53
56
  {
54
57
  rule: "方案名已被已装方案占用(拒装)",
@@ -103,6 +106,27 @@ describe("preflight 规则表(每规则一拒例 + 一过例)", () => {
103
106
  }
104
107
  });
105
108
 
109
+ describe("rEG-CS7 保留名(安装期 reserved_name 终止态,红队 F5)", () => {
110
+ it("name=oj/ojm/registry → 预检拒且 code=reserved_name,解压前即拒(stage 1 早于 download/extract)", async () => {
111
+ for (const bad of ["oj", "ojm", "registry"]) {
112
+ const m = structuredClone(VALID);
113
+ m.name = bad;
114
+ const r = await preflight(ctx({ manifest: m }));
115
+ expect(r.ok).toBe(false);
116
+ expect(r.code).toBe("reserved_name");
117
+ expect(r.retryable).toBe(false);
118
+ expect(r.message).toContain("为保留名");
119
+ }
120
+ });
121
+
122
+ it("真实第一方方案名(oj-registry)安装预检不被误伤", async () => {
123
+ const m = structuredClone(VALID);
124
+ m.name = "oj-registry";
125
+ const r = await preflight(ctx({ manifest: m }));
126
+ expect(r.code === "reserved_name").toBe(false);
127
+ });
128
+ });
129
+
106
130
  describe("preflight · C2-09 peerRuntime 范围算子语义", () => {
107
131
  it("过例:^ 同主版本且 ≥(宿主 0.1.80 满足 ^0.1.0)", async () => {
108
132
  const m = structuredClone(VALID);
@@ -181,6 +205,34 @@ describe("preflight · C2-11 peerPlatform 范围算子语义(^ 遵循 0.x 口
181
205
  expect(r.message).toContain("C2-11");
182
206
  });
183
207
 
208
+ // REG-R9 / 红队 F4:升 0.2.0 后声明 ^0.1.x 的存量包全部装不上。笼统回「不满足」会让人
209
+ // 去改方案版本/代码,真正要改的只有那一行 range——所以文案必须是可执行的迁移指令。
210
+ it("拒例(F4 升版反噬):host 0.2.x + ^0.1.x → 给出「改成 >=0.1.0」的专属迁移提示", async () => {
211
+ const m = structuredClone(VALID);
212
+ m.peerPlatform = "^0.1.0";
213
+ const r = await preflight(ctx({ manifest: m, hostApiVersion: "0.2.0" }));
214
+ expect(r.ok).toBe(false);
215
+ expect(r.message).toContain("C2-11");
216
+ expect(r.message).toContain(">=0.1.0");
217
+ // 要说清「不是本方案的问题」,否则发布方会去翻自己的代码
218
+ expect(r.message).toContain("不是本方案的问题");
219
+ expect(r.message).toContain("这一条要改的是声明,不是代码");
220
+ // 基线带 prerelease 时提示里只出现核心段(别把 -rc.1 拼进建议的 range 里)
221
+ m.peerPlatform = "^0.1.0-rc.1";
222
+ const r2 = await preflight(ctx({ manifest: m, hostApiVersion: "0.2.0" }));
223
+ expect(r2.message).toContain(">=0.1.0");
224
+ expect(r2.message).not.toContain(">=0.1.0-rc.1");
225
+ });
226
+
227
+ it("拒例(不该给迁移提示):host 0.1.x + ^0.2.0 是「包要求更高版本」,不是升版反噬", async () => {
228
+ const m = structuredClone(VALID);
229
+ m.peerPlatform = "^0.2.0";
230
+ const r = await preflight(ctx({ manifest: m, hostApiVersion: "0.1.80" }));
231
+ expect(r.ok).toBe(false);
232
+ expect(r.message).toContain("需升级宿主平台模块");
233
+ expect(r.message).not.toContain("改成「>=0.2.0」");
234
+ });
235
+
184
236
  it("过例:0.x 口径——^0.2.0 兼容 0.2.5(minor 相同,patch 更高)", async () => {
185
237
  const m = structuredClone(VALID);
186
238
  m.peerPlatform = "^0.2.0";
@@ -20,8 +20,9 @@ const KEY_ID = /^[a-z][a-z0-9-]*\.[a-z0-9-]+$/;
20
20
  const gen = (await import(GEN)) as {
21
21
  FIXTURE_NAME: string
22
22
  FIXTURE_VERSION: string
23
+ FIXTURE_REGISTRY_ID: string
23
24
  CANON: string
24
- canonicalString: (name: string, version: string, sha256: string) => string
25
+ canonicalString: (registryId: string, name: string, version: string, sha256: string) => string
25
26
  fingerprintOf: (publicKeyHex: string) => string
26
27
  generate: (outDir: string) => { sha256: string, canonical: string, paths: Record<string, string> }
27
28
  };
@@ -29,6 +30,7 @@ const gen = (await import(GEN)) as {
29
30
  describe("签名链夹具 · templates 面可达性与再生一致性(P2-0)", () => {
30
31
  const name = gen.FIXTURE_NAME;
31
32
  const version = gen.FIXTURE_VERSION;
33
+ const registryId = gen.FIXTURE_REGISTRY_ID;
32
34
  const tgzPath = path.join(SIG_DIR, `${name}-${version}.tgz`);
33
35
  const tgz = readFileSync(tgzPath);
34
36
  const sha256 = createHash("sha256").update(tgz).digest("hex");
@@ -38,6 +40,7 @@ describe("签名链夹具 · templates 面可达性与再生一致性(P2-0)"
38
40
  const sig = JSON.parse(readFileSync(`${tgzPath}${suffix}`, "utf8")) as Record<string, string>;
39
41
  expect(sig.alg).toBe("Ed25519");
40
42
  expect(sig.canon).toBe(gen.CANON);
43
+ expect(sig.registry_id).toBe(registryId);
41
44
  expect(sig.public_key).toMatch(HEX64);
42
45
  expect(sig.value).toMatch(STD_B64);
43
46
  expect(sig.value).not.toMatch(/[-_]/);
@@ -46,8 +49,8 @@ describe("签名链夹具 · templates 面可达性与再生一致性(P2-0)"
46
49
  });
47
50
 
48
51
  it("规范串与指纹从 templates 面计算与提交产物一致(三同一的夹具侧)", () => {
49
- const canonical = gen.canonicalString(name, version, sha256);
50
- expect(canonical).toBe(`${name}@${version}:${sha256}`);
52
+ const canonical = gen.canonicalString(registryId, name, version, sha256);
53
+ expect(canonical).toBe(`${registryId}:${name}@${version}:${sha256}`);
51
54
  const sig = JSON.parse(readFileSync(`${tgzPath}.sig`, "utf8")) as { public_key: string };
52
55
  expect(gen.fingerprintOf(sig.public_key)).toMatch(HEX64);
53
56
  });
@@ -56,7 +59,7 @@ describe("签名链夹具 · templates 面可达性与再生一致性(P2-0)"
56
59
  const out = path.join(os.tmpdir(), `ojm-sig-fixtures-tpl-${process.pid}`);
57
60
  const r = gen.generate(out);
58
61
  expect(r.sha256).toBe(sha256);
59
- expect(r.canonical).toBe(`${name}@${version}:${sha256}`);
62
+ expect(r.canonical).toBe(`${registryId}:${name}@${version}:${sha256}`);
60
63
  expect(readFileSync(r.paths.tgz).equals(tgz)).toBe(true);
61
64
  expect(readFileSync(r.paths.good, "utf8")).toBe(readFileSync(`${tgzPath}.sig`, "utf8"));
62
65
  expect(readFileSync(r.paths.bad, "utf8")).toBe(readFileSync(`${tgzPath}.bad.sig`, "utf8"));
@@ -0,0 +1,55 @@
1
+ // BDD:源代次水位(REG-CS2 防回放)——站点按源记住「见过的最大 seq」,回退即拒。
2
+ //
3
+ // 表 `reg_source_seq` 由 _platform 0014 迁移建;夹具直读真实迁移文件,与迁移零漂移。
4
+ // 四条负向是 G7 的重点:水位写坏 / 表不存在 / 值非法都必须**拒**,不能静默当「第一次见」——
5
+ // 静默重置等于把「防回放」降级成「每次都当第一次见」,攻击者先破坏状态再回放即可穿透。
6
+ import { beforeEach, describe, expect, it } from "vitest";
7
+ import { guardAndRecordSourceSeq, readSourceSeq, SOURCE_SEQ_TABLE } from "../../src/_platform/api/_lifecycle/source-seq";
8
+ import { installDb } from "../fixture";
9
+
10
+ const SRC = "https://cdn.example.com/solutions";
11
+
12
+ let dbo: any;
13
+
14
+ beforeEach(() => {
15
+ dbo = installDb();
16
+ });
17
+
18
+ describe("given 站点第一次见某个源 When 拉到它的索引", () => {
19
+ it("then 记录水位并放行(prev = null)", async () => {
20
+ expect(await readSourceSeq(SRC)).toBeNull();
21
+ expect(await guardAndRecordSourceSeq(SRC, 3)).toEqual({ ok: true, prev: null });
22
+ expect(await readSourceSeq(SRC)).toBe(3);
23
+ });
24
+ });
25
+
26
+ describe("given 同一代重复拉取 When 再判一次", () => {
27
+ it("then 幂等放行,且不改写水位(不误伤正常重复请求)", async () => {
28
+ await guardAndRecordSourceSeq(SRC, 5);
29
+ expect(await guardAndRecordSourceSeq(SRC, 5)).toEqual({ ok: true, prev: 5 });
30
+ expect(await readSourceSeq(SRC)).toBe(5);
31
+ });
32
+ });
33
+
34
+ describe("given 源返回更旧的代次 When 判水位", () => {
35
+ it("then 拒 index_stale,且不把水位拉低(回放改不了水位)", async () => {
36
+ await guardAndRecordSourceSeq(SRC, 7);
37
+ expect(await guardAndRecordSourceSeq(SRC, 3)).toEqual({ ok: false, code: "index_stale", prev: 7 });
38
+ expect(await readSourceSeq(SRC)).toBe(7);
39
+ });
40
+ });
41
+
42
+ describe("given 水位被写成非法值 When 判水位", () => {
43
+ it("then 按 index_state_corrupt 拒(不静默重置成「没见过」)", async () => {
44
+ dbo.prepare(`INSERT INTO ${SOURCE_SEQ_TABLE} (source_url, max_seq, updated_at) VALUES (?, ?, ?)`).run(SRC, 0, 1);
45
+ expect(await readSourceSeq(SRC)).toBe("corrupt");
46
+ expect(await guardAndRecordSourceSeq(SRC, 9)).toEqual({ ok: false, code: "index_state_corrupt", prev: null });
47
+ });
48
+ });
49
+
50
+ describe("given 水位表不存在(站点未 migrate)When 判水位", () => {
51
+ it("then 按 index_state_corrupt 拒(fail-closed,不悄悄放行)", async () => {
52
+ dbo.exec(`DROP TABLE ${SOURCE_SEQ_TABLE}`);
53
+ expect(await guardAndRecordSourceSeq(SRC, 1)).toEqual({ ok: false, code: "index_state_corrupt", prev: null });
54
+ });
55
+ });
@@ -266,6 +266,41 @@ describe("download(stage=downloading,S1-2.3 fetch→fs.writeFile)", () =>
266
266
  expect(r.message).toContain("C2-29");
267
267
  expect(calls).toBe(0);
268
268
  });
269
+
270
+ // ── REG-CS8 下载闸(红队 F1):先比对索引声明的 archive.size,超限直接拒——不发请求、不读入内存 ──
271
+ it("rEG-CS8 package_too_large:声明体积超限,fetch 零调用(源头堵死「先中毒后验毒」)", async () => {
272
+ const over = 50 * 1024 * 1024 + 1;
273
+ let calls = 0;
274
+ const r = await download(dlCtx({
275
+ manifest: manifest({ file: FILE, sha256: shaOf(BYTES), size: over }),
276
+ fetch: async () => {
277
+ calls++;
278
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
279
+ },
280
+ }));
281
+ expect(r.ok).toBe(false);
282
+ expect(r.retryable).toBe(false);
283
+ expect(r.code).toBe("package_too_large");
284
+ expect(r.message).toContain("REG-CS8");
285
+ expect(calls).toBe(0); // 关键:超限时 fetch 未被调用
286
+ });
287
+
288
+ it("rEG-CS8 边界:声明体积恰等于上限(50MB)放行,照常下载(fetch 收到等长字节,P2-11 通过)", async () => {
289
+ const atLimit = 50 * 1024 * 1024;
290
+ const big = new Uint8Array(atLimit);
291
+ const r = await download(dlCtx({
292
+ manifest: manifest({ file: FILE, sha256: shaOf(BYTES), size: atLimit }),
293
+ fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => big.slice().buffer as ArrayBuffer }),
294
+ }));
295
+ expect(r.ok).toBe(true);
296
+ });
297
+
298
+ it("rEG-CS8 边界:声明体积超 1 字节即拒(package_too_large 严格不等)", async () => {
299
+ const over = 50 * 1024 * 1024 + 1;
300
+ const r = await download(dlCtx({ manifest: manifest({ file: FILE, sha256: shaOf(BYTES), size: over }) }));
301
+ expect(r.ok).toBe(false);
302
+ expect(r.code).toBe("package_too_large");
303
+ });
269
304
  });
270
305
 
271
306
  describe("verify(stage=verifying,C2-29 sha256 fail-closed + P2-3 六码验签)", () => {
@@ -335,18 +370,40 @@ describe("verify(stage=verifying,C2-29 sha256 fail-closed + P2-3 六码验
335
370
  const badNs = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, key_id: "default" } }) }));
336
371
  expect(badNs).toMatchObject({ ok: false, code: "sig_key", retryable: false });
337
372
 
338
- // sig_format:public_key 非 64hex / value 含 -(base64url 特征字符)/ canon 非 v1
373
+ // sig_format:public_key 非 64hex / value 含 -(base64url 特征字符)
339
374
  const badKey = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, public_key: "abc" } }) }));
340
375
  expect(badKey).toMatchObject({ ok: false, code: "sig_format" });
341
376
  const b64url = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, value: `${good.value.slice(0, 10)}-${good.value.slice(11)}` } }) }));
342
377
  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" });
378
+ // canon_stale:v1 旧规范串(REG-CS3 不兼容旧 ojm 签的 .sig,一律逼重签)
379
+ const staleCanon = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, canon: "v1" } }) }));
380
+ expect(staleCanon).toMatchObject({ ok: false, code: "canon_stale" });
345
381
 
346
- // tampered:value 首字符翻转(仍是合法标准 base64)
382
+ // signature_mismatch:value 首字符翻转(仍是合法标准 base64)
347
383
  const tamperedValue = (good.value[0] === "A" ? "B" : "A") + good.value.slice(1);
348
384
  const tampered = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: { ...good, value: tamperedValue } }) }));
349
- expect(tampered).toMatchObject({ ok: false, code: "tampered", retryable: false });
385
+ expect(tampered).toMatchObject({ ok: false, code: "signature_mismatch", retryable: false });
386
+ });
387
+
388
+ it("rEG-CS3 canon_mismatch:包归属 registry 与本站不符(跨仓重放拒装)", async () => {
389
+ await seed();
390
+ const good = signedArchiveFor(BYTES, "demo-solution", "0.1.0").sig;
391
+ // 本站身份 oj-registry,但包是 test.registry 签发的 → 跨仓重放
392
+ const r = await verify(vfCtx({
393
+ registryId: "oj-registry",
394
+ manifest: manifest({ ...manifest().archive, signature: good }),
395
+ }));
396
+ expect(r).toMatchObject({ ok: false, code: "canon_mismatch" });
397
+ expect(r.message).toContain("oj-registry");
398
+ // 同身份则放行(回归:归属一致不误伤)
399
+ const okSame = await verify(vfCtx({
400
+ registryId: "test.registry",
401
+ manifest: manifest({ ...manifest().archive, signature: good }),
402
+ }));
403
+ expect(okSame).toMatchObject({ ok: true });
404
+ // 未配置 registryId = 跳过跨仓比对(仅验签完整性,向后兼容)
405
+ const okUnset = await verify(vfCtx({ manifest: manifest({ ...manifest().archive, signature: good }) }));
406
+ expect(okUnset).toMatchObject({ ok: true });
350
407
  });
351
408
 
352
409
  it("sig_key:已信任 key_id 但包内公钥与库绑定不一致(首次绑定不可改族)", async () => {
@@ -370,7 +427,7 @@ describe("verify(stage=verifying,C2-29 sha256 fail-closed + P2-3 六码验
370
427
  expect(await fsp.readdir(staging)).toEqual([FILE]);
371
428
  });
372
429
 
373
- it("双层语义分码:验签面抛错 → sig_format(上游 verifyEd25519: 前缀);返回 false → tampered", async () => {
430
+ it("双层语义分码:验签面抛错 → sig_format(上游 verifyEd25519: 前缀);返回 false → signature_mismatch", async () => {
374
431
  await seed();
375
432
  const throwing = await verify(vfCtx({
376
433
  verifyEd25519: () => { throw new Error("verifyEd25519: signature not base64"); },
@@ -378,7 +435,7 @@ describe("verify(stage=verifying,C2-29 sha256 fail-closed + P2-3 六码验
378
435
  expect(throwing).toMatchObject({ ok: false, code: "sig_format" });
379
436
  expect(throwing.message).toContain("verifyEd25519: ");
380
437
  const falseFace = await verify(vfCtx({ verifyEd25519: () => false }));
381
- expect(falseFace).toMatchObject({ ok: false, code: "tampered" });
438
+ expect(falseFace).toMatchObject({ ok: false, code: "signature_mismatch" });
382
439
  });
383
440
 
384
441
  it("原语/信任库不可得:验签面缺失或 trustPath 缺失 → fail-closed 拒装(无静默跳过)", async () => {
@@ -506,6 +563,108 @@ describe("extract(stage=extracting,R1 list→validate→extract 三步)",
506
563
  });
507
564
  });
508
565
 
566
+ // ── REG-CS8 体积闸(红队 F1):解压前复用同一份清单累加,零额外 tar、零解压前即拒;解压后复核兜底 ──
567
+ describe("extract REG-CS8 体积闸(清单口径,复用 list 输出,零额外 IO)", () => {
568
+ it("entry_too_large:单条目未压缩 > 50MB → 拒,code=entry_too_large,零解压(exec 仅 1 次列表)", async () => {
569
+ const big = 50 * 1024 * 1024 + 1;
570
+ const exec = recordingExec(async () => ({
571
+ code: 0,
572
+ stdout: [
573
+ "drwxr-xr-x 0 ever staff 0 10 17 12:00 demo-solution-0.1.0/",
574
+ `-rw-r--r-- 0 ever staff ${big} 10 17 12:00 demo-solution-0.1.0/bomb`,
575
+ ].join("\n"),
576
+ stderr: "",
577
+ }));
578
+ await fsp.mkdir(staging, { recursive: true });
579
+ const r = await extract(exCtx({ exec }));
580
+ expect(r.ok).toBe(false);
581
+ expect(r.retryable).toBe(false);
582
+ expect(r.code).toBe("entry_too_large");
583
+ expect(r.message).toContain("REG-CS8");
584
+ expect(exec.calls.length).toBe(1); // 复用清单,未触发第二次 tar(extract)
585
+ expect(await fsp.readdir(staging)).toEqual([]); // 零落盘
586
+ });
587
+
588
+ it("unpacked_too_large:未压缩总体积 > 200MB(单条目安全)→ 拒,code=unpacked_too_large,零解压", async () => {
589
+ const safe = 40 * 1024 * 1024;
590
+ const n = Math.ceil((200 * 1024 * 1024 + 1) / safe);
591
+ const lines: string[] = [];
592
+ for (let i = 0; i < n; i++)
593
+ lines.push(`-rw-r--r-- 0 ever staff ${safe} 10 17 12:00 demo-solution-0.1.0/f${i}`);
594
+ const exec = recordingExec(async () => ({ code: 0, stdout: lines.join("\n"), stderr: "" }));
595
+ await fsp.mkdir(staging, { recursive: true });
596
+ const r = await extract(exCtx({ exec }));
597
+ expect(r.ok).toBe(false);
598
+ expect(r.retryable).toBe(false);
599
+ expect(r.code).toBe("unpacked_too_large");
600
+ expect(exec.calls.length).toBe(1);
601
+ });
602
+
603
+ it("too_many_entries:条目数 > 5000 → 拒,code=too_many_entries,零解压", async () => {
604
+ const n = 5000 + 1;
605
+ const lines: string[] = [];
606
+ for (let i = 0; i < n; i++)
607
+ lines.push(`-rw-r--r-- 0 ever staff 1 10 17 12:00 demo-solution-0.1.0/f${i}`);
608
+ const exec = recordingExec(async () => ({ code: 0, stdout: lines.join("\n"), stderr: "" }));
609
+ await fsp.mkdir(staging, { recursive: true });
610
+ const r = await extract(exCtx({ exec }));
611
+ expect(r.ok).toBe(false);
612
+ expect(r.retryable).toBe(false);
613
+ expect(r.code).toBe("too_many_entries");
614
+ expect(exec.calls.length).toBe(1);
615
+ });
616
+
617
+ it("合法清单(成员数/总体积/单条目均安全)→ 越过体积闸,继续到 extract 步(exec.calls===2)", async () => {
618
+ // 真实系统 tar 解压此合法清单(list 成功→volume 通过→extract 真解压)
619
+ const realExec = realTarExec();
620
+ await fsp.mkdir(staging, { recursive: true });
621
+ const pkg = join(root, "pkg", "demo-solution-0.1.0");
622
+ await fsp.mkdir(join(pkg, "src"), { recursive: true });
623
+ await fsp.writeFile(join(pkg, "manifest.yaml"), "name: demo-solution\n");
624
+ await fsp.writeFile(join(pkg, "src", "entry.js"), "export default {};\n");
625
+ const tgz = join(staging, FILE);
626
+ await execFileP("tar", ["-czf", tgz, "-C", join(root, "pkg"), "demo-solution-0.1.0"]);
627
+ // 用真实 tar 回放同一 tgz 的 list/extract:list 输出即上面构造的合法形态
628
+ const r = await extract(exCtx({ exec: realExec }));
629
+ expect(r.ok).toBe(true);
630
+ expect(realExec.calls.length).toBe(2); // list + extract 各一次,体积闸未重复跑 tar
631
+ });
632
+ });
633
+
634
+ describe("extract REG-CS8 解压后复核(清单瞒报兜底:实际磁盘占用 > 200MB → 拒)", () => {
635
+ it("清单口径小(通过 volume 闸)但 stat 面报告实际占用超大 → 解压后复核拒,code=unpacked_too_large", async () => {
636
+ // 注入 stat 面:一切文件都报 1GB(模拟解压炸弹绕过清单口径);list 给一份小清单骗过 volume 闸
637
+ const hugeStat: FsFace = {
638
+ ...nodeFsFace(),
639
+ stat: async () => ({ size: 1024 * 1024 * 1024 }),
640
+ };
641
+ g.fs = hugeStat;
642
+ await fsp.mkdir(staging, { recursive: true });
643
+ await fsp.writeFile(join(staging, "manifest.yaml"), "name: demo-solution\n"); // 复核须读到的真实小文件
644
+ const exec = recordingExec(async argv => argv[1] === "-xzf"
645
+ ? { code: 0, stdout: "", stderr: "" } // 解压步:no-op(不真解压,靠 stat 面模拟实际占用)
646
+ : { code: 0, stdout: "-rw-r--r-- 0 ever staff 1 10 17 12:00 demo-solution-0.1.0/manifest.yaml", stderr: "" });
647
+ const r = await extract(exCtx({ exec }));
648
+ expect(r.ok).toBe(false);
649
+ expect(r.retryable).toBe(false);
650
+ expect(r.code).toBe("unpacked_too_large");
651
+ expect(r.message).toContain("REG-CS8");
652
+ expect(r.message).toContain("瞒报");
653
+ });
654
+
655
+ it("stat 面缺失 → 复核降级跳过(清单口径已先拦,不误杀正常包)", async () => {
656
+ // nodeFsFace 无 stat:recheck 应 ok 跳过;用真实小包验证不误杀
657
+ await fsp.mkdir(staging, { recursive: true });
658
+ const pkg = join(root, "pkg2", "demo-solution-0.1.0");
659
+ await fsp.mkdir(pkg, { recursive: true });
660
+ await fsp.writeFile(join(pkg, "manifest.yaml"), "name: demo-solution\n");
661
+ const tgz = join(staging, FILE);
662
+ await execFileP("tar", ["-czf", tgz, "-C", join(root, "pkg2"), "demo-solution-0.1.0"]);
663
+ const r = await extract(exCtx({ exec: realTarExec() }));
664
+ expect(r.ok).toBe(true); // 无 stat 面正常包照常通过(复核跳过而非失败)
665
+ });
666
+ });
667
+
509
668
  // B-11/C2-31 安装期复核:包内 api/<name>-<version>/manifest.yaml 是 oj 原生清单(desc 必填,
510
669
  // serve 装配 parse_one 消费面),与 C2 Form A 的 summary 字段由构建期映射(ojm build --bundle
511
670
  // 写入并核对);安装期只验不写——写等于掩盖上游不一致(映射被篡改的包必须拒装)。
@@ -4,7 +4,8 @@
4
4
  // 拒绝:绝对路径、任何 `..` 段、l/h 条目、空成员名、反斜杠路径;`./` 前缀相对合法。
5
5
  import { describe, expect, it } from "vitest";
6
6
 
7
- import { validateTarListing } from "../../src/_platform/api/_lifecycle/tgz-guard";
7
+ import { MAX_ENTRIES, MAX_PACKAGE_BYTES, MAX_SINGLE_ENTRY_BYTES, MAX_UNPACKED_BYTES } from "../../src/_platform/api/_lifecycle/limits";
8
+ import { accumulateTarVolume, checkTarVolumeLimits, extractTarEntrySize, validateTarListing } from "../../src/_platform/api/_lifecycle/tgz-guard";
8
9
 
9
10
  describe("validateTarListing(R1)", () => {
10
11
  it("正常成员列表通过", () => {
@@ -101,3 +102,134 @@ describe("validateTarListing(R1)", () => {
101
102
  ])).toEqual({ ok: false, offender: "/abs" });
102
103
  });
103
104
  });
105
+
106
+ // ── REG-CS8 体积闸门(红队 F1):复用同一份 tar 清单累加,不额外跑一次 tar ──
107
+
108
+ describe("extractTarEntrySize(REG-CS8 跨实现形态稳健解析)", () => {
109
+ it("gNU 月日形(-rw-r--r-- … SIZE 10 17 12:00 name)解析出 size", () => {
110
+ expect(extractTarEntrySize("-rw-r--r-- 0 ever staff 42 10 17 12:00 wiki-0.2.0/src/manifest.yaml")).toBe(42);
111
+ });
112
+
113
+ it("gNU-ISO 形(-rw-r--r-- … SIZE 2026-10-17 12:00 name)解析出 size", () => {
114
+ expect(extractTarEntrySize("-rw-r--r-- 0 ever staff 42 2026-10-17 12:00 wiki-0.2.0/src/manifest.yaml")).toBe(42);
115
+ });
116
+
117
+ it("bSD 形(-rw-r--r-- … SIZE Mon DD HH:MM name)解析出 size", () => {
118
+ expect(extractTarEntrySize("-rw-r--r-- 0 ever staff 42 Oct 17 12:00 wiki-0.2.0/src/manifest.yaml")).toBe(42);
119
+ });
120
+
121
+ it("目录行(size=0)解析为 0,不影响体积判定", () => {
122
+ expect(extractTarEntrySize("drwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/")).toBe(0);
123
+ });
124
+
125
+ it("畸形行(无日期组)回退 0,不抛错", () => {
126
+ expect(extractTarEntrySize("-rw-r--r-- 0 ever staff")).toBe(0);
127
+ });
128
+
129
+ it("gNU 旧 mtime 形(省略时刻,只剩 ISO 日期)解析出 size,不漏体积闸门", () => {
130
+ expect(extractTarEntrySize("-rw-r--r-- 0 ever staff 42 2026-10-17 wiki-0.2.0/src/manifest.yaml")).toBe(42);
131
+ });
132
+
133
+ it("bSD 旧 mtime 形(省略时刻,年份替代)解析出 size,不漏体积闸门", () => {
134
+ expect(extractTarEntrySize("-rw-r--r-- 0 ever staff 42 Oct 17 2023 wiki-0.2.0/src/manifest.yaml")).toBe(42);
135
+ });
136
+
137
+ it("旧 mtime 大单条目仍命中 entry_too_large(REG-CS8 不漏)", () => {
138
+ const big = MAX_SINGLE_ENTRY_BYTES + 1;
139
+ const r = checkTarVolumeLimits([
140
+ `-rw-r--r-- 0 ever staff ${big} 2026-10-17 wiki-0.2.0/bomb`,
141
+ ]);
142
+ expect(r.ok).toBe(false);
143
+ if (!r.ok)
144
+ expect(r.code).toBe("entry_too_large");
145
+ });
146
+ });
147
+
148
+ describe("accumulateTarVolume(REG-CS8 累加统计)", () => {
149
+ it("累加条目数/未压缩总体积/单条目最大体积", () => {
150
+ const v = accumulateTarVolume([
151
+ "drwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/",
152
+ "-rw-r--r-- 0 ever staff 10 10 17 12:00 wiki-0.2.0/a",
153
+ "-rw-r--r-- 0 ever staff 30 10 17 12:00 wiki-0.2.0/b",
154
+ ]);
155
+ expect(v).toEqual({ entries: 3, totalUncompressed: 40, maxEntry: 30 });
156
+ });
157
+
158
+ it("空行/退化行跳过不计数", () => {
159
+ const v = accumulateTarVolume(["", " ", "-rw-r--r-- 0 ever staff 7 10 17 12:00 wiki-0.2.0/x"]);
160
+ expect(v).toEqual({ entries: 1, totalUncompressed: 7, maxEntry: 7 });
161
+ });
162
+ });
163
+
164
+ describe("checkTarVolumeLimits(REG-CS8 三闸:条目数 → 总体积 → 单条目,先罚整体再罚单点)", () => {
165
+ it("合法清单通过,并回传 volume", () => {
166
+ const lines = [
167
+ "drwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/",
168
+ "-rw-r--r-- 0 ever staff 42 10 17 12:00 wiki-0.2.0/src/manifest.yaml",
169
+ ];
170
+ const r = checkTarVolumeLimits(lines);
171
+ expect(r.ok).toBe(true);
172
+ if (r.ok)
173
+ expect(r.volume).toEqual({ entries: 2, totalUncompressed: 42, maxEntry: 42 });
174
+ });
175
+
176
+ it("too_many_entries:条目数 > 上限(MAX_ENTRIES)即拒", () => {
177
+ const lines: string[] = [];
178
+ for (let i = 0; i < MAX_ENTRIES + 1; i++)
179
+ lines.push(`-rw-r--r-- 0 ever staff 1 10 17 12:00 wiki-0.2.0/f${i}`);
180
+ const r = checkTarVolumeLimits(lines);
181
+ expect(r.ok).toBe(false);
182
+ if (!r.ok) {
183
+ expect(r.code).toBe("too_many_entries");
184
+ expect(r.offender).toContain(String(MAX_ENTRIES + 1));
185
+ }
186
+ });
187
+
188
+ it("unpacked_too_large:未压缩总体积 > 上限(MAX_UNPACKED_BYTES)即拒", () => {
189
+ // 单条目均 < 上限(40MB),靠总和越界——确认命中「总体积闸」而非「单条目闸」
190
+ const safe = 40 * 1024 * 1024;
191
+ const n = Math.ceil((MAX_UNPACKED_BYTES + 1) / safe);
192
+ const lines: string[] = [];
193
+ for (let i = 0; i < n; i++)
194
+ lines.push(`-rw-r--r-- 0 ever staff ${safe} 10 17 12:00 wiki-0.2.0/f${i}`);
195
+ const r = checkTarVolumeLimits(lines);
196
+ expect(r.ok).toBe(false);
197
+ if (!r.ok) {
198
+ expect(r.code).toBe("unpacked_too_large");
199
+ expect(r.volume.entries).toBe(n);
200
+ }
201
+ });
202
+
203
+ it("entry_too_large:单条目未压缩体积 > 上限(MAX_SINGLE_ENTRY_BYTES)即拒", () => {
204
+ const big = MAX_SINGLE_ENTRY_BYTES + 1;
205
+ const r = checkTarVolumeLimits([
206
+ "drwxr-xr-x 0 ever staff 0 10 17 12:00 wiki-0.2.0/",
207
+ `-rw-r--r-- 0 ever staff ${big} 10 17 12:00 wiki-0.2.0/bomb`,
208
+ ]);
209
+ expect(r.ok).toBe(false);
210
+ if (!r.ok) {
211
+ expect(r.code).toBe("entry_too_large");
212
+ expect(r.offender).toContain(String(big));
213
+ }
214
+ });
215
+
216
+ it("三闸序位:条目数越界先于总体积/单条目(批量伪造清单优先罚整体维度)", () => {
217
+ // 同一条清单既条目数越界、又单条目越界——应报 too_many_entries(先罚整体)
218
+ const lines: string[] = [];
219
+ for (let i = 0; i < MAX_ENTRIES + 1; i++)
220
+ lines.push(`-rw-r--r-- 0 ever staff ${MAX_SINGLE_ENTRY_BYTES + 1} 10 17 12:00 wiki-0.2.0/f${i}`);
221
+ const r = checkTarVolumeLimits(lines);
222
+ expect(r.ok).toBe(false);
223
+ if (!r.ok)
224
+ expect(r.code).toBe("too_many_entries");
225
+ });
226
+ });
227
+
228
+ describe("体积上限常量(REG-CS8,Q13 拍板数值,全仓单源)", () => {
229
+ it("四常数口径与需求一致(50MB/200MB/5000/50MB)", () => {
230
+ expect(MAX_PACKAGE_BYTES).toBe(50 * 1024 * 1024);
231
+ expect(MAX_UNPACKED_BYTES).toBe(200 * 1024 * 1024);
232
+ expect(MAX_ENTRIES).toBe(5000);
233
+ expect(MAX_SINGLE_ENTRY_BYTES).toBe(50 * 1024 * 1024);
234
+ });
235
+ });
@@ -54,17 +54,27 @@ describe("SSRF 钉①:探测 URL 永不出站(probeUrlOf)", () => {
54
54
  });
55
55
  });
56
56
 
57
- // ============ SSRF 钉②:siteOrigin header 清洗 ============
58
- describe("SSRF 钉②:siteOrigin 清洗 x-forwarded-proto", () => {
57
+ // ============ SSRF 钉②:siteOrigin 只信配置的 canonical_origin,绝不读请求头(REG-CS6)============
58
+ describe("SSRF 钉②:siteOrigin 只信配置的 canonical_origin,绝不读请求头", () => {
59
59
  afterEach(() => {
60
60
  g.http = undefined;
61
61
  });
62
- it("取首个且去空白", () => {
63
- g.http = { headers: { "host": "h", "x-forwarded-proto": " https , http " } };
64
- expect(siteOrigin()).toEqual({ proto: "https", host: "h" });
62
+ it("合法 origin → {proto,host}(协议小写化)", () => {
63
+ expect(siteOrigin("https://Example.com:8080")).toEqual({ proto: "https", host: "Example.com:8080" });
64
+ expect(siteOrigin("http://h")).toEqual({ proto: "http", host: "h" });
65
65
  });
66
- it("无 host → null(非请求上下文跳过探针)", () => {
67
- g.http = { headers: {} };
66
+ it("缺省/空/非法 → null(不补全、不回落请求头)", () => {
67
+ expect(siteOrigin()).toBeNull();
68
+ expect(siteOrigin(null)).toBeNull();
69
+ expect(siteOrigin("")).toBeNull();
70
+ expect(siteOrigin("not-a-url")).toBeNull();
71
+ expect(siteOrigin("ftp://x")).toBeNull();
72
+ });
73
+ it("伪造 Host/x-forwarded-proto 头完全不被采用——只认入参 canonical_origin", () => {
74
+ g.http = { headers: { "host": "169.254.169.254", "x-forwarded-proto": "http" } };
75
+ // 入参给出真实 origin → 用真实 origin;请求头里的恶意 host 被无视
76
+ expect(siteOrigin("https://real.example.com")).toEqual({ proto: "https", host: "real.example.com" });
77
+ // 入参缺失 → 即便请求头伪造了 host 也不猜、不回落,返回 null
68
78
  expect(siteOrigin()).toBeNull();
69
79
  });
70
80
  });
@@ -270,18 +280,20 @@ afterEach(async () => {
270
280
  });
271
281
 
272
282
  function setHttp(over: Record<string, unknown>) {
273
- g.http = { user: { id: 1 }, query: { name: "demo" }, headers: { host: "localhost:9778" }, ...over };
283
+ // 默认伪造一个与 canonical_origin 不同的 Host 头(REG-CS6:探针补全绝不读请求头 Host,
284
+ // 即便此处 host 是攻击者的恶意值,出站 URL 也只认配置的 canonical_origin)
285
+ g.http = { user: { id: 1 }, query: { name: "demo" }, headers: { host: "forged.attacker.invalid" }, ...over };
274
286
  g.json = {
275
287
  ok: (data: unknown) => { last = { status: 200, data }; },
276
288
  fail: (status: number, msg: string) => { last = { status, msg }; },
277
289
  };
278
290
  }
279
291
 
280
- function makeEnv() {
292
+ function makeEnv(overrides: Record<string, unknown> = {}) {
281
293
  // modulesJsonPath 钉进临时目录(readFile 失败 → 不打 web 项):默认值 `dist/web/modules.json`
282
294
  // 是相对 cwd 的,会把模板本机构建产物卷进用例,全绿用例随「模板 build 过没有」摇摆。
283
295
  // P4-2 两条 integrity 用例显式覆盖它。
284
- return { ...solutionsEnv(), sharedRoot: root, statePath: join(root, "state.yaml"), modulesJsonPath: join(root, "modules.json") };
296
+ return { ...solutionsEnv(), sharedRoot: root, statePath: join(root, "state.yaml"), modulesJsonPath: join(root, "modules.json"), ...overrides };
285
297
  }
286
298
 
287
299
  describe("深检端点门(admin 域)", () => {
@@ -307,22 +319,24 @@ describe("深检端点编排(四项)", () => {
307
319
  dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", null, 1);
308
320
  // trusted 状态:无 trust 项、无检查项 → 干净报告(checks=[])
309
321
  await fsp.writeFile(join(root, "state.yaml"), "solutions:\n demo:\n version: \"0.1.0\"\n enabled: true\n installedAt: 0\n trust_state: trusted\n");
322
+ // REG-CS6:探针补全用配置的 canonical_origin(不读请求头 Host)
310
323
  setHttp({});
311
- await createSolutionsApi(makeEnv()).verify.post();
324
+ await createSolutionsApi(makeEnv({ canonicalOrigin: "http://localhost:9778" })).verify.post();
312
325
  expect(last?.status).toBe(200);
313
326
  const data = last?.data as { ok: boolean, checks: unknown[], advise: string[] };
314
327
  expect(data.ok).toBe(true);
315
328
  expect(data.checks).toEqual([]);
316
329
  expect(data.advise).toEqual([]);
317
- // 探针用同源绝对 URL(SSRF 钉①②)
330
+ // 探针按 canonical_origin 补全为同源绝对 URL(SSRF 钉①②)
318
331
  expect(fetchCalls[0]).toBe("http://localhost:9778/api/demo");
319
332
  });
320
333
 
321
334
  it("路由 404 → routes error 项,ok=false", async () => {
322
335
  dbo.prepare("INSERT INTO sys_solution_route (path, module, category, \"order\", title_key, required_permission, enabled, deprecated) VALUES (?,?,?,?,?,?,?,0)").run("/api/demo", "demo", "main", 200, "demo:menu", null, 1);
323
336
  g.fetch = statusFetch(404);
337
+ // REG-CS6:探针需配置 canonical_origin 才会真正出站(缺配置则跳过 info 项)
324
338
  setHttp({});
325
- await createSolutionsApi(makeEnv()).verify.post();
339
+ await createSolutionsApi(makeEnv({ canonicalOrigin: "http://localhost:9778" })).verify.post();
326
340
  const data = last?.data as { ok: boolean, checks: Array<{ id: string, severity: string }> };
327
341
  expect(data.ok).toBe(false);
328
342
  expect(data.checks.some(c => c.id === "routes" && c.severity === "error")).toBe(true);
@@ -129,7 +129,9 @@ function setHttp(over: Record<string, unknown> = {}) {
129
129
  function makeEnv() {
130
130
  // modulesJsonPath 同 verify-checks.spec:钉进临时目录(缺文件 → 跳过 web 项),
131
131
  // 不吃模板本机 `dist/web/modules.json`。
132
- return { ...solutionsEnv(), sharedRoot: root, statePath: join(root, "state.yaml"), modulesJsonPath: join(root, "modules.json"), verifyReportsDir: reportsDir };
132
+ // REG-CS6:深检路由可达性探针需配置 canonical_origin 才会真正出站(缺配置则跳过并打 info 项,
133
+ // 会让「全绿无检查项」类断言多出一项)。此处注入固定 origin + okFetch(200) → 探针通过不打项。
134
+ return { ...solutionsEnv(), sharedRoot: root, statePath: join(root, "state.yaml"), modulesJsonPath: join(root, "modules.json"), verifyReportsDir: reportsDir, canonicalOrigin: "http://localhost:9778" };
133
135
  }
134
136
 
135
137
  async function insertRoute(perm: string | null) {