@oj-module/cli 0.2.30 → 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.
- package/devkit/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// REG-CS2 索引新鲜度(防回放/降级):站点按源记住「见过的最大 seq」,回退即拒。
|
|
2
|
+
//
|
|
3
|
+
// 记在站点 DB(`reg_source_seq`,0014 迁移)而不记在 CLI:受害者是**站点**——镜像把旧索引
|
|
4
|
+
// 回放给谁,谁就是被降级的一方(D1 已定,CLI/API 一致)。CLI 侧 `ojm market verify` 是
|
|
5
|
+
// 离线单点校验,没有「上次见过的 seq」,故不做这项判定。
|
|
6
|
+
//
|
|
7
|
+
// fail-closed 口径(与 REG-CS1 同源):
|
|
8
|
+
// - 读不到 / 读到非正整数 / 写入失败 —— 一律按 `index_state_corrupt` 拒,不静默重置。
|
|
9
|
+
// 静默重置等于把「防回放」降级成「每次都当第一次见」,攻击者先破坏状态再回放即穿。
|
|
10
|
+
// - 表不存在同样算读不到:迁移由 _platform 自带,起不来就该响,不该悄悄放行。
|
|
11
|
+
|
|
12
|
+
// db 为 oj isolate 注入全局(同模板 service 层惯例,history.ts 同款;测试由夹具装 globalThis.db)
|
|
13
|
+
declare const db: { table: (name: string) => any };
|
|
14
|
+
|
|
15
|
+
export const SOURCE_SEQ_TABLE = "reg_source_seq";
|
|
16
|
+
|
|
17
|
+
/** 判定:ok = 放行并已记录;!ok = 拒,`code` 区分「回放/降级」与「状态损坏」 */
|
|
18
|
+
export type SourceSeqVerdict = {
|
|
19
|
+
ok: true
|
|
20
|
+
prev: number | null
|
|
21
|
+
} | {
|
|
22
|
+
ok: false
|
|
23
|
+
code: "index_stale" | "index_state_corrupt"
|
|
24
|
+
prev: number | null
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 比对并推进某个源的最大 seq。
|
|
29
|
+
*
|
|
30
|
+
* - 首次见(无行)→ 记录并放行;
|
|
31
|
+
* - 同 seq 重复拉取 → 放行(幂等,不误伤正常重复请求),不重复写;
|
|
32
|
+
* - seq 回退(< 记录值)→ `index_stale` 拒,且不覆盖记录(不能让回放把水位拉低)。
|
|
33
|
+
*/
|
|
34
|
+
export async function guardAndRecordSourceSeq(source: string, seq: number): Promise<SourceSeqVerdict> {
|
|
35
|
+
const prev = await readSourceSeq(source);
|
|
36
|
+
if (prev === "corrupt")
|
|
37
|
+
return { ok: false, code: "index_state_corrupt", prev: null };
|
|
38
|
+
if (prev !== null && seq < prev)
|
|
39
|
+
return { ok: false, code: "index_stale", prev };
|
|
40
|
+
if (prev === seq)
|
|
41
|
+
return { ok: true, prev };
|
|
42
|
+
try {
|
|
43
|
+
await writeSourceSeq(source, seq);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 写失败按 fail-closed 转「状态损坏」——不静默重置(否则防回放被降级成「每次都当第一次见」)
|
|
47
|
+
return { ok: false, code: "index_state_corrupt", prev: null };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, prev };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 读取记录:无行 → null;读不到或非正整数 → "corrupt"(bigint 是 pg/mysql 的整型口径,一并认) */
|
|
53
|
+
export async function readSourceSeq(source: string): Promise<number | null | "corrupt"> {
|
|
54
|
+
let rows: Array<{ max_seq?: unknown }>;
|
|
55
|
+
try {
|
|
56
|
+
rows = await db.table(SOURCE_SEQ_TABLE)
|
|
57
|
+
.select(["max_seq"])
|
|
58
|
+
.where({ field: "source_url", op: "eq", value: source })
|
|
59
|
+
.limit(1)
|
|
60
|
+
.all() as Array<{ max_seq?: unknown }>;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return "corrupt";
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
66
|
+
return null;
|
|
67
|
+
const raw = rows[0]?.max_seq;
|
|
68
|
+
if (typeof raw === "bigint")
|
|
69
|
+
return raw >= 1n ? Number(raw) : "corrupt";
|
|
70
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
|
|
71
|
+
return raw;
|
|
72
|
+
return "corrupt";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 记录某源的最大 seq:先 insert(首见),撞唯一键(并发首见同源)则降级为 update。
|
|
77
|
+
* 这样消除「SELECT 再 INSERT」的 TOCTOU——并发首装同源不再因唯一键冲突被误判成
|
|
78
|
+
* index_state_corrupt;任何写失败仍向上抛,由 guard 按 fail-closed 转成 index_state_corrupt。
|
|
79
|
+
*/
|
|
80
|
+
export async function writeSourceSeq(source: string, seq: number): Promise<void> {
|
|
81
|
+
const now = Math.floor(Date.now() / 1000);
|
|
82
|
+
try {
|
|
83
|
+
await db.table(SOURCE_SEQ_TABLE).insert({ source_url: source, max_seq: seq, updated_at: now }).run();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
await db.table(SOURCE_SEQ_TABLE).update({ max_seq: seq, updated_at: now }).where({ field: "source_url", op: "eq", value: source }).run();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { InstallContext } from "./preflight";
|
|
8
8
|
import { fsFace } from "../atomic";
|
|
9
|
+
import { MAX_PACKAGE_BYTES } from "../limits";
|
|
9
10
|
|
|
10
11
|
/** 最小 fetch 面(结构化:Node fetch/Response 天然满足;测试注入 stub 不真跑网络) */
|
|
11
12
|
export type FetchFace = (url: string) => Promise<{
|
|
@@ -28,6 +29,8 @@ export interface StageResult {
|
|
|
28
29
|
ok: boolean
|
|
29
30
|
message: string
|
|
30
31
|
retryable?: boolean
|
|
32
|
+
/** 终止态错误码(installer 据此置 task.error.reason);体积闸门命中恒 package_too_large */
|
|
33
|
+
code?: string
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -62,6 +65,18 @@ export async function download(ctx: DownloadContext): Promise<StageResult> {
|
|
|
62
65
|
return { stage: "downloading", ok: false, retryable: false, message: `下载失败:archive.file「${arch.file}」须逐字为 <name>-<version>.tgz(「${expected}」,P2-11 索引投毒加固)` };
|
|
63
66
|
}
|
|
64
67
|
const dest = `${ctx.staging}/${arch.file}`;
|
|
68
|
+
// REG-CS8 下载闸(红队 F1):先比对索引声明的 archive.size,超限直接拒——不发请求、不读入内存,
|
|
69
|
+
// 把「先中毒后验毒」的源头堵死(限量值全仓单源,与解压闸/上传侧共用 MAX_PACKAGE_BYTES)。
|
|
70
|
+
const declaredSize = arch.archive.size;
|
|
71
|
+
if (typeof declaredSize === "number" && Number.isFinite(declaredSize) && declaredSize > MAX_PACKAGE_BYTES) {
|
|
72
|
+
return {
|
|
73
|
+
stage: "downloading",
|
|
74
|
+
ok: false,
|
|
75
|
+
retryable: false,
|
|
76
|
+
code: "package_too_large",
|
|
77
|
+
message: `下载失败:分发包声明体积 ${declaredSize} 字节超过上限 ${MAX_PACKAGE_BYTES}(REG-CS8 package_too_large)——拒绝下载以省带宽与内存,请发布方拆分或压缩后重试`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
65
80
|
try {
|
|
66
81
|
const res = await ctx.fetch(ctx.archiveUrl);
|
|
67
82
|
if (!res.ok) {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { InstallContext } from "./preflight";
|
|
11
11
|
import { fsFace } from "../atomic";
|
|
12
|
-
import {
|
|
12
|
+
import { MAX_UNPACKED_BYTES } from "../limits";
|
|
13
|
+
import { checkTarVolumeLimits, validateTarListing } from "../tgz-guard";
|
|
13
14
|
import { archiveFileOf } from "./download";
|
|
14
15
|
|
|
15
16
|
export interface ExecResult {
|
|
@@ -49,6 +50,8 @@ export interface StageResult {
|
|
|
49
50
|
ok: boolean
|
|
50
51
|
message: string
|
|
51
52
|
retryable?: boolean
|
|
53
|
+
/** 终止态错误码(installer 据此置 task.error.reason);体积闸门命中恒为体积类 code */
|
|
54
|
+
code?: string
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
@@ -94,6 +97,17 @@ export async function extract(ctx: ExtractContext): Promise<StageResult> {
|
|
|
94
97
|
message: `解压失败:包内容未通过安全校验(R1:offender「${v.offender}」为越界/链接成员)——零解压零落盘,附录 A extracting:若反复出现请勿使用该分发包`,
|
|
95
98
|
};
|
|
96
99
|
}
|
|
100
|
+
// 第二步(续)REG-CS8 体积闸门:复用同一份清单累加,零额外 IO、零解压前即拒
|
|
101
|
+
const vol = checkTarVolumeLimits(lines);
|
|
102
|
+
if (!vol.ok) {
|
|
103
|
+
return {
|
|
104
|
+
stage: "extracting",
|
|
105
|
+
ok: false,
|
|
106
|
+
retryable: false,
|
|
107
|
+
code: vol.code,
|
|
108
|
+
message: `解压失败:包内容超出体积上限(REG-CS8:${vol.code},offender「${vol.offender}」)——零解压零落盘,附录 A extracting:请发布方拆分或压缩后重试`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
97
111
|
// 第三步 extract:校验通过才落盘;-C staging 就位即止,rename 由 P1-10 落锁段接管
|
|
98
112
|
const extracted = await ctx.exec.run(buildExtractCmd(file, ctx.staging));
|
|
99
113
|
if (extracted.code !== 0) {
|
|
@@ -109,7 +123,19 @@ export async function extract(ctx: ExtractContext): Promise<StageResult> {
|
|
|
109
123
|
if (!c31.ok) {
|
|
110
124
|
return { stage: "extracting", ok: false, retryable: false, message: c31.message };
|
|
111
125
|
}
|
|
112
|
-
|
|
126
|
+
// REG-CS8 解压后复核:清单尺寸可能由畸形 tar 瞒报(解压炸弹绕过清单口径),以实际磁盘占用兜底。
|
|
127
|
+
// 仅当 stat 面可用时执行(oj 真机提供;测试/缺失则降级跳过,LIST 闸已先拦)。
|
|
128
|
+
const recheck = await recheckStagingOccupancy(ctx);
|
|
129
|
+
if (!recheck.ok) {
|
|
130
|
+
return {
|
|
131
|
+
stage: "extracting",
|
|
132
|
+
ok: false,
|
|
133
|
+
retryable: false,
|
|
134
|
+
code: "unpacked_too_large",
|
|
135
|
+
message: `解压失败:实际磁盘占用 ${recheck.bytes} 字节超过上限 ${MAX_UNPACKED_BYTES}(REG-CS8 解压后复核:清单尺寸瞒报,已清场)——附录 A extracting:请发布方拆分或压缩后重试`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { stage: "extracting", ok: true, message: `解压完成:${lines.length} 个成员 → ${ctx.staging}/(单根已归平,§2.9 布局消费点;C2-31 复核通过;REG-CS8 解压后复核通过;目录 rename 由 P1-10 落锁段接管)` };
|
|
113
139
|
}
|
|
114
140
|
catch (e) {
|
|
115
141
|
return { stage: "extracting", ok: false, retryable: true, message: `解压失败:${String(e)}——附录 A extracting(S1-2 exec.run tar 三步范式)` };
|
|
@@ -239,3 +265,36 @@ export async function flattenBundleRoot(staging: string): Promise<void> {
|
|
|
239
265
|
await f.remove(root, { recursive: true });
|
|
240
266
|
}
|
|
241
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* REG-CS8 解压后实际磁盘占用复核(解压炸弹绕过清单口径的兜底):递归累加 staging 全部文件体积。
|
|
271
|
+
* 仅当 stat 面可用时执行;读盘失败/缺面 → 降级跳过(LIST 闸已先拦,不误杀正常包)。
|
|
272
|
+
* 注:staging 顶层的 .tgz 一并计入(已压缩,≤ MAX_PACKAGE_BYTES,对 200MB 阈值保守不影响正常包)。
|
|
273
|
+
*/
|
|
274
|
+
async function recheckStagingOccupancy(ctx: ExtractContext): Promise<{ ok: true } | { ok: false, bytes: number }> {
|
|
275
|
+
const f = fsFace();
|
|
276
|
+
if (f.stat === undefined)
|
|
277
|
+
return { ok: true };
|
|
278
|
+
let total = 0;
|
|
279
|
+
const walk = async (dir: string): Promise<void> => {
|
|
280
|
+
const names = await f.readdir(dir);
|
|
281
|
+
for (const name of names) {
|
|
282
|
+
const p = `${dir}/${name}`;
|
|
283
|
+
try {
|
|
284
|
+
await f.readdir(p); // 失败 = 普通文件(与 flattenBundleRoot 同手法)
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
total += (await f.stat!(p)).size;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
await walk(p);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
await walk(ctx.staging);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return { ok: true };
|
|
298
|
+
}
|
|
299
|
+
return total > MAX_UNPACKED_BYTES ? { ok: false, bytes: total } : { ok: true };
|
|
300
|
+
}
|
|
@@ -19,11 +19,15 @@ export interface StageResult {
|
|
|
19
19
|
ok: boolean
|
|
20
20
|
message: string
|
|
21
21
|
retryable?: boolean
|
|
22
|
+
/** 终止态错误码(安装器据此置 task.error.reason);保留名命中恒 reserved_name */
|
|
23
|
+
code?: string
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface Violation {
|
|
25
27
|
clause: string
|
|
26
28
|
detail: string
|
|
29
|
+
/** 终止态错误码(安装器据此置 task.error.reason);保留名命中恒 reserved_name */
|
|
30
|
+
code?: string
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
// ── 契约 §2/§4 正则与清单(与契约现行文本逐字对应,改动须先改契约再同步夹具与双端实现)──
|
|
@@ -31,8 +35,13 @@ interface Violation {
|
|
|
31
35
|
/** C2-01:name 白名单 */
|
|
32
36
|
const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
33
37
|
|
|
34
|
-
/** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9
|
|
35
|
-
const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
|
|
38
|
+
/** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留)。REG-CS7 扩项:oj/ojm/registry 一并保留 */
|
|
39
|
+
const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system", "oj", "ojm", "registry"]);
|
|
40
|
+
|
|
41
|
+
/** 保留名替代建议(REG-CS7:给可落地的 kebab-case 候选,而非只说「非法」;两候选恒非保留名) */
|
|
42
|
+
function reservedNameSuggestions(name: string): [string, string] {
|
|
43
|
+
return [`${name}-app`, `meta-${name}`];
|
|
44
|
+
}
|
|
36
45
|
|
|
37
46
|
/** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
|
|
38
47
|
const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
|
|
@@ -152,6 +161,34 @@ function caretSatisfies(host: string, base: string): boolean {
|
|
|
152
161
|
return hm === 0 && hi === 0 && hp === bp;
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
/**
|
|
165
|
+
* 升版反噬(红队 F4)的专属迁移提示:`host 0.2.x` + 声明 `^0.1.x`。
|
|
166
|
+
*
|
|
167
|
+
* 0.x 的 caret 语义是「minor 必须相等」(见 caretSatisfies),所以平台小版本一升,
|
|
168
|
+
* 所有声明 `^0.1.0` 的存量包**同时**装不上。这不是包写错了,是升级带来的兼容断点——
|
|
169
|
+
* 笼统回「不满足 peerPlatform」会把发布方引向「升方案版本」「改代码」两个错方向,
|
|
170
|
+
* 真正要改的只有这一行 range。
|
|
171
|
+
*
|
|
172
|
+
* 只对 0.x 给建议:`^1.0.0` 在 2.0.0 宿主上不满足是标准的语义化版本预期,没有迁移可言。
|
|
173
|
+
*/
|
|
174
|
+
function caretUpgradeAdvice(host: string, op: string | undefined, base: string): string | null {
|
|
175
|
+
if (op !== "^")
|
|
176
|
+
return null;
|
|
177
|
+
const h = coreParts(host);
|
|
178
|
+
const b = coreParts(base);
|
|
179
|
+
if (h === null || b === null)
|
|
180
|
+
return null;
|
|
181
|
+
if (h[0] !== 0 || b[0] !== 0 || h[1] <= b[1])
|
|
182
|
+
return null;
|
|
183
|
+
return `这是平台小版本升级带来的兼容断点(0.x 下 ^ 只匹配同 minor:^${coreOf(base)} 等价于 ${coreOf(base).split(".")[0]}.${b[1]}.*),不是本方案的问题。请把 peerPlatform 改成「>=${coreOf(base)}」后重新打包——这一条要改的是声明,不是代码`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 版本核心段文本(`0.1.0-rc.1` → `0.1.0`;cit:提示文案里只出现核心段,别把 prerelease 带进去) */
|
|
187
|
+
function coreOf(v: string): string {
|
|
188
|
+
const [major, minor, patch] = coreParts(v) ?? [0, 0, 0];
|
|
189
|
+
return `${major}.${minor}.${patch}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
155
192
|
export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
156
193
|
const v: Violation[] = [];
|
|
157
194
|
const m = ctx.manifest;
|
|
@@ -173,7 +210,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
|
173
210
|
v.push({ clause: "C2-01", detail: `name「${String(m.name)}」不匹配 ^[a-z][a-z0-9-]{0,63}$(小写字母开头,仅小写字母/数字/连字符)` });
|
|
174
211
|
}
|
|
175
212
|
if (RESERVED_NAMES.has(name)) {
|
|
176
|
-
|
|
213
|
+
const [a1, a2] = reservedNameSuggestions(name);
|
|
214
|
+
v.push({ clause: "C2-01", code: "reserved_name", detail: `name「${name}」为保留名(§4.2:${[...RESERVED_NAMES].join("/")}),禁用于方案(R9);替代名建议:${a1} / ${a2}` });
|
|
177
215
|
}
|
|
178
216
|
|
|
179
217
|
// ── C2-02 version:字符集 + oj 相容排除 ──
|
|
@@ -236,7 +274,13 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
|
236
274
|
v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/_platform/market/meta 不可用或本仓 API_VERSION 未注入),peerPlatform「${range}」强制比对失败(fail-closed)` });
|
|
237
275
|
}
|
|
238
276
|
else if (!rangeSatisfies(host, parsed[1], parsed[2])) {
|
|
239
|
-
|
|
277
|
+
const advice = caretUpgradeAdvice(host, parsed[1], parsed[2]);
|
|
278
|
+
v.push({
|
|
279
|
+
clause: "C2-11",
|
|
280
|
+
detail: advice === null
|
|
281
|
+
? `宿主平台 API 版本 ${host} 不满足 peerPlatform 声明「${range}」(需升级宿主平台模块)`
|
|
282
|
+
: `宿主平台 API 版本 ${host} 不满足 peerPlatform 声明「${range}」。${advice}`,
|
|
283
|
+
});
|
|
240
284
|
}
|
|
241
285
|
}
|
|
242
286
|
}
|
|
@@ -263,7 +307,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
|
263
307
|
else {
|
|
264
308
|
apiSeg = p.slice("/api/".length);
|
|
265
309
|
if (RESERVED_NAMES.has(apiSeg)) {
|
|
266
|
-
|
|
310
|
+
const [a1, a2] = reservedNameSuggestions(apiSeg);
|
|
311
|
+
v.push({ clause: "C2-13", code: "reserved_name", detail: `api.prefix 模块段「${apiSeg}」为保留名(§4.2:${[...RESERVED_NAMES].join("/")}),禁用;替代名建议:${a1} / ${a2}` });
|
|
267
312
|
}
|
|
268
313
|
if (apiSeg !== name) {
|
|
269
314
|
const occ = occupier(apiSeg);
|
|
@@ -341,7 +386,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
|
341
386
|
else {
|
|
342
387
|
const seg = rp.slice(1);
|
|
343
388
|
if (RESERVED_NAMES.has(seg)) {
|
|
344
|
-
|
|
389
|
+
const [a1, a2] = reservedNameSuggestions(seg);
|
|
390
|
+
v.push({ clause: "C2-14", code: "reserved_name", detail: `web.route_prefix 首段「${seg}」为保留名(§4.2:${[...RESERVED_NAMES].join("/")}),禁用;替代名建议:${a1} / ${a2}` });
|
|
345
391
|
}
|
|
346
392
|
if (seg !== name) {
|
|
347
393
|
const occ = occupier(seg);
|
|
@@ -581,10 +627,13 @@ function report(v: Violation[]): StageResult {
|
|
|
581
627
|
if (v.length === 0) {
|
|
582
628
|
return { stage: "preflight", ok: true, message: "预检通过:manifest 满足 C2 契约(身份/兼容/占用/匿名/RBAC/路由/设置/health 安全预检全部通过)" };
|
|
583
629
|
}
|
|
630
|
+
// 保留名命中(reserved_name)优先作为终止态错误码(红队 F5:撞保留名是确定性拒绝,非可重试)
|
|
631
|
+
const coded = v.find(x => x.code !== undefined);
|
|
584
632
|
return {
|
|
585
633
|
stage: "preflight",
|
|
586
634
|
ok: false,
|
|
587
635
|
retryable: false,
|
|
636
|
+
code: coded?.code,
|
|
588
637
|
message: `预检未通过(${v.length} 项违规,全量列出):\n${v.map(x => `- 【${x.clause}】${x.detail}`).join("\n")}`,
|
|
589
638
|
};
|
|
590
639
|
}
|
|
@@ -117,7 +117,8 @@ export async function registerSolution(decl: SolutionDecl): Promise<RegisterResu
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// RBAC(唯一出口):upsert + 声明消失标 deprecated,deprecated 清单原样汇入
|
|
120
|
-
|
|
120
|
+
// permissions 透传顶层声明——无角色的权限点只能靠它落库(少了这行 = 管理面绑不了)
|
|
121
|
+
const perms = await upsertSolutionPerms({ module: decl.module, roles: decl.roles, permissions: decl.permissions });
|
|
121
122
|
|
|
122
123
|
// 设置项 upsert:存在只覆定义列(value/update 语义 = 管理员改过的值不复活,AC1-15),
|
|
123
124
|
// 不存在插 default_value(首装 value)
|
|
@@ -26,6 +26,12 @@ export interface VerifyContext extends InstallContext {
|
|
|
26
26
|
trustPath?: string
|
|
27
27
|
/** Ed25519 验签面;缺省回落 globalThis.crypto.verifyEd25519 */
|
|
28
28
|
verifyEd25519?: VerifyEd25519Face
|
|
29
|
+
/**
|
|
30
|
+
* 本站点(registry)身份(REG-CS3 v2 防跨仓库重放):签名 .sig.registry_id 必须与之一致,
|
|
31
|
+
* 否则视为「A 签的包被 B 的 CDN 安装」→ canon_mismatch 拒装。缺省 undefined = 不启用跨仓比对
|
|
32
|
+
* (仅验签完整性),由站点装配时填入自身 registry id 才生效。
|
|
33
|
+
*/
|
|
34
|
+
registryId?: string
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export interface StageResult {
|
|
@@ -41,8 +47,8 @@ export interface StageResult {
|
|
|
41
47
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
42
48
|
/** 标准 base64(含 -/_ 即 base64url,一律 sig_format,禁归一) */
|
|
43
49
|
const STD_B64 = /^[A-Z0-9+/]+={0,2}$/i;
|
|
44
|
-
/**
|
|
45
|
-
const CANON = "
|
|
50
|
+
/** 规范串版本标记(REG-CS3 v2;不认识即 canon_stale 拒装) */
|
|
51
|
+
const CANON = "v2";
|
|
46
52
|
|
|
47
53
|
interface Signature {
|
|
48
54
|
alg: unknown
|
|
@@ -50,6 +56,8 @@ interface Signature {
|
|
|
50
56
|
public_key: unknown
|
|
51
57
|
value: unknown
|
|
52
58
|
canon?: unknown
|
|
59
|
+
/** 签发方 registry 身份(REG-CS3 v2 规范串首段,跨仓重放判定真源) */
|
|
60
|
+
registry_id?: unknown
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/** 签名失败结果统一出口(六码:retryable 恒 false、零解压零落盘;unknown_key 附 needsTrust) */
|
|
@@ -160,9 +168,9 @@ async function verifySignature(ctx: VerifyContext, bytes: Uint8Array, sha256: st
|
|
|
160
168
|
if (typeof sig.value !== "string" || !STD_B64.test(sig.value)) {
|
|
161
169
|
return sigFail("sig_format", "校验失败:签名 value 必须是标准 base64(sig_format;base64url 含 -/_ 不受理,不做归一)");
|
|
162
170
|
}
|
|
163
|
-
// ⑤ canon
|
|
164
|
-
if (sig.canon !==
|
|
165
|
-
return sigFail("
|
|
171
|
+
// ⑤ canon 版本标记(REG-CS3:v1 旧格式一律拒,逼重签;缺失即拒,不兼容旧 ojm)
|
|
172
|
+
if (sig.canon !== CANON) {
|
|
173
|
+
return sigFail("canon_stale", `校验失败:规范串版本过旧(canon_stale;本机要求 ${CANON},实为「${String(sig.canon)}」)——请用当前 ojm 重签,旧版 .sig 不再受理`);
|
|
166
174
|
}
|
|
167
175
|
// ⑥ 验签原语可得性(与 sha256Hex 同口径:不可得 = 拒装,不静默跳过)
|
|
168
176
|
const face = verifyEd25519FaceOf(ctx);
|
|
@@ -186,8 +194,14 @@ async function verifySignature(ctx: VerifyContext, bytes: Uint8Array, sha256: st
|
|
|
186
194
|
if (trustedKey !== sig.public_key) {
|
|
187
195
|
return sigFail("sig_key", `校验失败:key_id「${sig.key_id}」的包内公钥与信任库绑定不一致(sig_key;首次绑定不可改——疑抢注/换钥,请发布方轮换为新 key_id)`);
|
|
188
196
|
}
|
|
189
|
-
// ⑧ 规范串 + 验签(双层语义:抛错 = sig_format,false =
|
|
190
|
-
|
|
197
|
+
// ⑧ 规范串 + 验签(双层语义:抛错 = sig_format,false = signature_mismatch,同拒装但分码)
|
|
198
|
+
// REG-CS3 v2:规范串首段 = 签发方 registry 身份,防「A 签的包被 B 的 CDN 安装」跨仓重放。
|
|
199
|
+
const sigRegistryId = typeof sig.registry_id === "string" ? sig.registry_id : "";
|
|
200
|
+
const canonical = `${sigRegistryId}:${ctx.name}@${ctx.version}:${sha256}`;
|
|
201
|
+
// REG-CS3 跨仓重放:声明的 registry_id 必须与本站点身份一致(env.registryId 未配置则跳过此比对)
|
|
202
|
+
if (ctx.registryId !== undefined && sigRegistryId !== ctx.registryId) {
|
|
203
|
+
return sigFail("canon_mismatch", `校验失败:包归属 registry「${sigRegistryId}」与本站「${ctx.registryId}」不符(canon_mismatch;REG-CS3 防跨仓库重放)——疑似被挪到非签发方 registry 的 CDN 安装,已拒装`);
|
|
204
|
+
}
|
|
191
205
|
let verified: boolean;
|
|
192
206
|
try {
|
|
193
207
|
verified = face(sig.public_key, canonical, sig.value);
|
|
@@ -197,7 +211,7 @@ async function verifySignature(ctx: VerifyContext, bytes: Uint8Array, sha256: st
|
|
|
197
211
|
return sigFail("sig_format", `校验失败:验签输入格式错(sig_format):${String(e)}`);
|
|
198
212
|
}
|
|
199
213
|
if (!verified) {
|
|
200
|
-
return sigFail("
|
|
214
|
+
return sigFail("signature_mismatch", "校验失败:签名与包体不符(signature_mismatch;REG-CS3 v2 规范串含 registry_id,签名不符不落盘——请重新从可信源获取分发包与签名)");
|
|
201
215
|
}
|
|
202
216
|
return {
|
|
203
217
|
stage: "verifying",
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// 拒绝:绝对路径、任何 `..` 段、l/h 条目、空成员名、反斜杠路径(防御性);
|
|
6
6
|
// `./` 前缀视为相对合法(归一化后必须在包根内)。
|
|
7
7
|
|
|
8
|
+
import { MAX_ENTRIES, MAX_SINGLE_ENTRY_BYTES, MAX_UNPACKED_BYTES } from "./limits";
|
|
9
|
+
|
|
8
10
|
function extractMemberName(line: string): string {
|
|
9
11
|
const arrow = line.indexOf(" -> ");
|
|
10
12
|
const entry = arrow === -1 ? line : line.slice(0, arrow);
|
|
@@ -39,3 +41,97 @@ export function validateTarListing(lines: string[]): { ok: true } | { ok: false,
|
|
|
39
41
|
}
|
|
40
42
|
return { ok: true };
|
|
41
43
|
}
|
|
44
|
+
|
|
45
|
+
// ── REG-CS8 体积闸门(红队 F1):复用同一份 tar 清单累加体积/条目数/单条目最大体积,不额外跑一次 tar ──
|
|
46
|
+
|
|
47
|
+
/** 体积闸门错误码(installer 据此置 task.error.reason;retryable 恒 false——体积超限非可重试) */
|
|
48
|
+
export type VolumeCode = "too_many_entries" | "unpacked_too_large" | "entry_too_large";
|
|
49
|
+
|
|
50
|
+
export interface TarVolume {
|
|
51
|
+
/** 成员条目数(含目录条目) */
|
|
52
|
+
entries: number
|
|
53
|
+
/** 未压缩总体积(字节) */
|
|
54
|
+
totalUncompressed: number
|
|
55
|
+
/** 单条目最大未压缩体积(字节) */
|
|
56
|
+
maxEntry: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 从 `tar -tvzf` verbose 行提取未压缩体积(字节)。
|
|
61
|
+
* 体积恒位于「日期组」紧邻左侧、成员名之前;日期组跨实现形态:
|
|
62
|
+
* GNU: <size> [MM DD | YYYY-MM-DD] [HH:MM] —— 旧 mtime(>6 月)省略时刻,只剩日期
|
|
63
|
+
* BSD: <size> Mon DD [HH:MM | YYYY] —— 旧 mtime 省略时刻,只剩年份
|
|
64
|
+
* 旧实现只锚定 HH:MM,旧 mtime 行(无时刻)回退 0,使大包绕过 REG-CS8 体积闸门(zip bomb)。
|
|
65
|
+
* 这里分两路:含时刻的行仍按时间回退定位(兼容 GNU-ISO / GNU 月日 / BSD 三态);
|
|
66
|
+
* 无时刻的旧 mtime 行改锚定「日期组」(ISO 日期或月份名),取前一 token 为 size。
|
|
67
|
+
* 无日期组的畸形行回退 0。
|
|
68
|
+
*/
|
|
69
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
70
|
+
const MONTH = /^[A-Z]{3}$/i;
|
|
71
|
+
const TIME = /^\d{1,2}:\d{2}(?::\d{2})?$/;
|
|
72
|
+
const DAY_OR_YEAR = /^\d{1,2}$|^\d{4}$/;
|
|
73
|
+
|
|
74
|
+
/** 定位日期组首 token 索引:含时刻则回退时间锚点;否则锚定 ISO 日期或「月份名 + 其后跟日/年份」 */
|
|
75
|
+
function findDateGroupStart(tokens: string[]): number {
|
|
76
|
+
const timeIdx = tokens.findIndex(t => TIME.test(t));
|
|
77
|
+
if (timeIdx >= 1)
|
|
78
|
+
return ISO_DATE.test(tokens[timeIdx - 1]!) ? timeIdx - 1 : timeIdx - 2;
|
|
79
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
80
|
+
const t = tokens[i]!;
|
|
81
|
+
if (ISO_DATE.test(t))
|
|
82
|
+
return i;
|
|
83
|
+
if (MONTH.test(t)) {
|
|
84
|
+
const next = tokens[i + 1] ?? "";
|
|
85
|
+
if (DAY_OR_YEAR.test(next))
|
|
86
|
+
return i;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function extractTarEntrySize(line: string): number {
|
|
93
|
+
const tokens = line.trim().split(/\s+/);
|
|
94
|
+
if (tokens.length < 2)
|
|
95
|
+
return 0;
|
|
96
|
+
const dateGroupStart = findDateGroupStart(tokens);
|
|
97
|
+
if (dateGroupStart < 1)
|
|
98
|
+
return 0;
|
|
99
|
+
const n = Number(tokens[dateGroupStart - 1]!.replace(/,/g, ""));
|
|
100
|
+
return Number.isFinite(n) ? n : 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 累加清单体积统计(条目数 / 未压缩总体积 / 单条目最大体积) */
|
|
104
|
+
export function accumulateTarVolume(lines: string[]): TarVolume {
|
|
105
|
+
let entries = 0;
|
|
106
|
+
let totalUncompressed = 0;
|
|
107
|
+
let maxEntry = 0;
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (line.trim() === "")
|
|
110
|
+
continue;
|
|
111
|
+
entries++;
|
|
112
|
+
const size = extractTarEntrySize(line);
|
|
113
|
+
totalUncompressed += size;
|
|
114
|
+
if (size > maxEntry)
|
|
115
|
+
maxEntry = size;
|
|
116
|
+
}
|
|
117
|
+
return { entries, totalUncompressed, maxEntry };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 体积闸门(REG-CS8):复用 validateTarListing 已消费的同一份 tar 清单逐项累加——
|
|
122
|
+
* 条目数 / 未压缩总体积 / 单条目最大体积任一超限即拒,零解压零落盘。
|
|
123
|
+
* 检查序:条目数 → 总体积 → 单条目(先罚「整体维度」,再罚「单点维度」)。
|
|
124
|
+
*/
|
|
125
|
+
export function checkTarVolumeLimits(lines: string[]): { ok: true, volume: TarVolume } | { ok: false, code: VolumeCode, offender: string, volume: TarVolume } {
|
|
126
|
+
const volume = accumulateTarVolume(lines);
|
|
127
|
+
if (volume.entries > MAX_ENTRIES) {
|
|
128
|
+
return { ok: false, code: "too_many_entries", offender: `${volume.entries} 个成员(上限 ${MAX_ENTRIES})`, volume };
|
|
129
|
+
}
|
|
130
|
+
if (volume.totalUncompressed > MAX_UNPACKED_BYTES) {
|
|
131
|
+
return { ok: false, code: "unpacked_too_large", offender: `${volume.totalUncompressed} 字节(上限 ${MAX_UNPACKED_BYTES})`, volume };
|
|
132
|
+
}
|
|
133
|
+
if (volume.maxEntry > MAX_SINGLE_ENTRY_BYTES) {
|
|
134
|
+
return { ok: false, code: "entry_too_large", offender: `${volume.maxEntry} 字节(上限 ${MAX_SINGLE_ENTRY_BYTES})`, volume };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, volume };
|
|
137
|
+
}
|
|
@@ -41,8 +41,11 @@ export interface StageResult {
|
|
|
41
41
|
retryable?: boolean
|
|
42
42
|
/** 取消安装(U5,P3-8):本 stage 被用户中止,运行器据其走「清场 + 锁/目录回退装前 + 审计末位」语义 */
|
|
43
43
|
cancelled?: boolean
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* 终止态错误码(installer 据此置 task.error.reason;各 step 自带字面量集合——签名六码见 SigCode、
|
|
46
|
+
* 保留名 reserved_name、体积闸 package_too_large 等;统一收敛为 string 供编排层记录与 i18n 兜底)
|
|
47
|
+
*/
|
|
48
|
+
code?: string
|
|
46
49
|
/** 未知 key_id 待 TOFU 确认:任务停 needs_trust 终态,staging 不清场供确认后重试 */
|
|
47
50
|
needsTrust?: boolean
|
|
48
51
|
/** 待确认的发布方 key(TaskRecord.trust 同源;UI/CLI 展示指纹) */
|
|
@@ -53,7 +56,7 @@ export interface StageResult {
|
|
|
53
56
|
* 签名失败六码(契约 v1.3 附录 A.2,P2-3):retryable 恒 false、零解压零落盘。
|
|
54
57
|
* 文案 key = `lifecycle.stage.verifying.<code>`(双语值 P2-5 落 locales)。
|
|
55
58
|
*/
|
|
56
|
-
export type SigCode = "sig_missing" | "sig_alg" | "sig_key" | "tampered" | "unknown_key" | "sig_format";
|
|
59
|
+
export type SigCode = "sig_missing" | "sig_alg" | "sig_key" | "tampered" | "signature_mismatch" | "canon_stale" | "canon_mismatch" | "unknown_key" | "sig_format";
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
62
|
* 安装阶段名(契约附录 A 十阶段逐字 + D11①扩展组 lock_commit + audit/done 终态)。
|
|
@@ -222,6 +222,14 @@ export interface SolutionsEnv {
|
|
|
222
222
|
statePath: string
|
|
223
223
|
modulesJsonPath?: string
|
|
224
224
|
apiPrefix?: string
|
|
225
|
+
/**
|
|
226
|
+
* canonical origin(REG-CS6 Host 头 SSRF 修复):深检探针补全用的站点权威 origin,
|
|
227
|
+
* 取自 config.yaml `vars.canonical_origin`,**绝不读请求头 Host/x-forwarded-proto**。
|
|
228
|
+
* 缺失 → null,探针跳过(info 项),不猜、不回落请求头。
|
|
229
|
+
*/
|
|
230
|
+
canonicalOrigin?: string
|
|
231
|
+
/** 站点 config.yaml 路径(canonical origin 读取处;缺省 config.yaml) */
|
|
232
|
+
configPath?: string
|
|
225
233
|
/** 深检报告留存目录(默认随 statePath 同目录 verify-reports;P2-8) */
|
|
226
234
|
verifyReportsDir?: string
|
|
227
235
|
fetch?: FetchInitFace
|
|
@@ -15,6 +15,13 @@ export interface SolutionRoleDecl {
|
|
|
15
15
|
export interface SolutionManifest {
|
|
16
16
|
module: string
|
|
17
17
|
roles: SolutionRoleDecl[]
|
|
18
|
+
/**
|
|
19
|
+
* 顶层 `permissions[]` 的 code 清单(契约 C2-15 的声明面)。
|
|
20
|
+
* 与 `roles[].permissions` **不是**上下级关系:C2-16 只要求角色引用的 code 先在此声明,
|
|
21
|
+
* 反过来「声明了但没有任何角色引用」是合法形态(系统预置权限点,由管理员在管理面自行分配)。
|
|
22
|
+
* 漏收集的后果是这类权限点根本不落库 → 管理面绑不上 → 声明形同虚设。
|
|
23
|
+
*/
|
|
24
|
+
permissions?: string[]
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface UpsertResult {
|
|
@@ -25,7 +32,8 @@ export interface UpsertResult {
|
|
|
25
32
|
export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertResult> {
|
|
26
33
|
const deprecated: string[] = [];
|
|
27
34
|
const declaredRoles = new Set(m.roles.map(r => r.name));
|
|
28
|
-
|
|
35
|
+
// 权限点全集 = 角色引用的 ∪ 顶层独立声明的(后者是无角色权限点唯一能落库的来源)
|
|
36
|
+
const declaredPerms = new Set([...m.roles.flatMap(r => r.permissions ?? []), ...(m.permissions ?? [])]);
|
|
29
37
|
|
|
30
38
|
for (const role of m.roles) {
|
|
31
39
|
const existing = await db.table("sys_role").select(["name"]).where({ field: "name", op: "eq", value: role.name }).all() as unknown[];
|