@oj-module/cli 0.2.30 → 0.2.43
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 +237 -13
- 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 +220 -53
- 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 +46 -96
- 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
|
@@ -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[];
|
|
@@ -3,32 +3,83 @@
|
|
|
3
3
|
// 单一真源:market/api.ts re-export 保持既有引用面;深检/方案域直接 import 本模块。
|
|
4
4
|
|
|
5
5
|
import type { FetchFace } from "../_lifecycle/steps/download";
|
|
6
|
+
import { fsFace } from "../_lifecycle/atomic";
|
|
6
7
|
|
|
7
8
|
/** 带缓存指令的拉取面(FetchFace 的超集:oj 全局 fetch/测试 stub 天然满足,少参函数可赋多参形) */
|
|
8
9
|
export type FetchInitFace = (url: string, init?: { headers?: Record<string, string>, redirect?: string }) => Promise<{
|
|
9
10
|
ok: boolean
|
|
10
11
|
status: number
|
|
11
12
|
arrayBuffer: () => Promise<ArrayBuffer>
|
|
13
|
+
/**
|
|
14
|
+
* 响应头读取面(REG-CS5 读面缓存):读 ETag 发 If-None-Match → 304 不重传正文。
|
|
15
|
+
* 可选字段:真实 fetch 的 Response.headers 天然满足;不暴露响应头的测试 stub 省略即按
|
|
16
|
+
* 「无 ETag 语义」退化为每次全量拉取——语义正确,只是少了条件请求的省字节。
|
|
17
|
+
*/
|
|
18
|
+
headers?: { get: (name: string) => string | null }
|
|
12
19
|
}>;
|
|
13
20
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
/**
|
|
22
|
+
* 站点 canonical origin(REG-CS6 Host 头 SSRF 修复):**绝不**读请求头 host / x-forwarded-proto——
|
|
23
|
+
* 伪造 Host(如 169.254.169.254 云元数据)若被用来补全相对 URL,出站请求会被牵引到攻击者指定的
|
|
24
|
+
* 源。一律走站点配置 `vars.canonical_origin`(模板 config.yaml 随发,部署侧填真实对外地址)。
|
|
25
|
+
*
|
|
26
|
+
* - 给定合法 origin(http(s)://host,无路径)→ 返回 {proto, host};
|
|
27
|
+
* - 缺失 / 非法 / 空 → 返回 null(不补全、不回落请求头;相对 URL 原样透传,由下层 fetch 判不可用)。
|
|
28
|
+
* 这是 fail-closed:没有可信源就不替请求「猜」一个,避免把 Host 头当可信输入。
|
|
29
|
+
*/
|
|
30
|
+
export function siteOrigin(canonicalOrigin?: string | null): { proto: string, host: string } | null {
|
|
31
|
+
if (!canonicalOrigin || !canonicalOrigin.trim()) {
|
|
19
32
|
return null;
|
|
20
33
|
}
|
|
21
|
-
const
|
|
22
|
-
|
|
34
|
+
const m = /^(https?):\/\/([^/?#]+)$/i.exec(canonicalOrigin.trim());
|
|
35
|
+
if (m === null) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return { proto: m[1]!.toLowerCase(), host: m[2]! };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读站点 canonical origin(config.yaml `vars.canonical_origin`;缺文件/缺字段/解析失败 → null)。
|
|
43
|
+
* 隔离运行时无 node:fs/yaml:统一走 fsFace() + globalThis.yaml(与 _platform 其余读盘一致)。
|
|
44
|
+
* 异步:config 读取在隔离环境只有异步面,故 caller(marketEnv/solutionsEnv 是同步的)改为惰性、请求内解析。
|
|
45
|
+
* 进程级缓存:canonical_origin 是部署期安全设置,不会随请求热更,按 configPath 缓存避免每次请求重复读盘。
|
|
46
|
+
*/
|
|
47
|
+
const canonicalOriginCache = new Map<string, Promise<string | null>>();
|
|
48
|
+
|
|
49
|
+
export function readCanonicalOrigin(configPath?: string): Promise<string | null> {
|
|
50
|
+
if (!configPath) {
|
|
51
|
+
return Promise.resolve(null);
|
|
52
|
+
}
|
|
53
|
+
const cached = canonicalOriginCache.get(configPath);
|
|
54
|
+
if (cached !== undefined) {
|
|
55
|
+
return cached;
|
|
56
|
+
}
|
|
57
|
+
const p = (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const bytes = await fsFace().readFile(configPath);
|
|
60
|
+
// yaml.parse 是异步面(oj 隔离全局,全仓消费方同一口径)——必须 await:
|
|
61
|
+
// 漏 await 时 doc 是 Promise,vars 恒 undefined,config 里的 canonical_origin 永远读不到。
|
|
62
|
+
const doc = await (globalThis as { yaml?: { parse: (t: string) => Promise<unknown> } }).yaml?.parse(new TextDecoder().decode(bytes)) as { vars?: { canonical_origin?: unknown } } | undefined;
|
|
63
|
+
const v = doc?.vars?.canonical_origin;
|
|
64
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
canonicalOriginCache.set(configPath, p);
|
|
71
|
+
return p;
|
|
23
72
|
}
|
|
24
73
|
|
|
25
|
-
/** 站点源补全(深检探针复用 siteOrigin;相对 URL → 绝对同源 URL
|
|
26
|
-
export function withSiteOrigin(fetch: FetchInitFace): FetchFace {
|
|
27
|
-
const o = siteOrigin();
|
|
74
|
+
/** 站点源补全(深检探针复用 siteOrigin;相对 URL → 绝对同源 URL;无可信源则不补全) */
|
|
75
|
+
export function withSiteOrigin(fetch: FetchInitFace, canonicalOrigin?: string | null): FetchFace {
|
|
76
|
+
const o = siteOrigin(canonicalOrigin);
|
|
28
77
|
if (o === null) {
|
|
29
78
|
return fetch as FetchFace;
|
|
30
79
|
}
|
|
31
|
-
|
|
80
|
+
// init(如 REG-CS5 条件请求的 if-none-match)必须透传——否则按源补全会静默丢掉调用方的请求头。
|
|
81
|
+
// init 形参可选,故仍可赋给单参的 FetchFace。
|
|
82
|
+
return (url: string, init?: { headers?: Record<string, string>, redirect?: string }) => fetch(/^https?:\/\//i.test(url) ? url : `${o.proto}://${o.host}${url}`, init) as ReturnType<FetchFace>;
|
|
32
83
|
}
|
|
33
84
|
|
|
34
85
|
/** 站点迁移账本 head(oj journal 真源 `_oj_migrations`;表缺失/查询失败 → null = 跳过禁降版判定,不猜) */
|