@oj-module/cli 0.2.6 → 0.2.14
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/README.md +1 -1
- package/devkit/README.md +70 -24
- package/devkit/framework-dev.md +14 -5
- package/devkit/manual.md +28 -23
- package/devkit/module-dev-and-cdn-playground.md +197 -0
- package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +130 -45
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +64 -0
- package/src/__tests__/versions.test.ts +26 -0
- package/src/build.ts +9 -0
- package/src/bundle.ts +8 -5
- package/src/contract/__tests__/handler-scan.test.ts +29 -0
- package/src/contract/__tests__/market-client.integration.test.ts +170 -0
- package/src/contract/handler-scan.ts +35 -4
- package/src/dev.ts +10 -2
- package/src/index.ts +9 -3
- package/src/init.ts +24 -0
- package/src/lifecycle/__tests__/bundle.test.ts +3 -3
- package/src/market/__tests__/bootstrap.test.ts +204 -0
- package/src/market/__tests__/build-index.test.ts +154 -0
- package/src/market/__tests__/cdn-layout.test.ts +96 -0
- package/src/market/__tests__/failure-playbook.test.ts +33 -0
- package/src/market/__tests__/fixture.test.ts +48 -0
- package/src/market/__tests__/publish.test.ts +109 -0
- package/src/market/__tests__/report.test.ts +51 -0
- package/src/market/__tests__/setup.test.ts +114 -0
- package/src/market/__tests__/verify.test.ts +111 -0
- package/src/market/bootstrap.ts +255 -0
- package/src/market/build-index.ts +159 -0
- package/src/market/cdn-layout.ts +122 -0
- package/src/market/command.ts +139 -0
- package/src/market/failure-playbook.ts +57 -0
- package/src/market/fixture.ts +138 -0
- package/src/market/index-builder.ts +76 -0
- package/src/market/publish.ts +156 -0
- package/src/market/report.ts +67 -0
- package/src/market/scan.ts +99 -0
- package/src/market/setup.ts +144 -0
- package/src/market/trial.ts +121 -0
- package/src/market/verify.ts +298 -0
- package/src/market/walkthrough.ts +506 -0
- package/src/oj.ts +3 -1
- package/src/preview-config.ts +177 -0
- package/src/preview.ts +106 -56
- package/src/sign.ts +21 -6
- package/src/usage.ts +22 -2
- package/src/vendor.ts +4 -2
- package/src/versions.ts +28 -0
- package/templates/api/.ojm-api-exempt.json +6 -2
- package/templates/api/config.yaml +24 -2
- package/templates/api/src/_platform/README.md +31 -0
- package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
- package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
- package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
- package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
- package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
- package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
- package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
- package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
- package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
- package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
- package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
- package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
- package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
- package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
- package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
- package/templates/api/src/_platform/contacts/api.ts +2 -2
- package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
- package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
- package/templates/api/src/_platform/manifest.yaml +4 -1
- package/templates/api/src/_platform/market/api.ts +54 -16
- package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
- package/templates/api/src/_platform/schema.yaml +26 -0
- package/templates/api/src/_platform/solutions/api.ts +13 -12
- package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
- package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
- package/templates/api/src/auth/_shared/session.ts +26 -22
- package/templates/api/src/auth/api.ts +1 -1
- package/templates/api/src/demo/todos/api.ts +8 -8
- package/templates/api/src/home/line/api.ts +4 -4
- package/templates/api/src/home/pie/api.ts +4 -4
- package/templates/api/src/notification/_shared/notify.ts +97 -57
- package/templates/api/src/notification/api.ts +1 -1
- package/templates/api/src/notification/manifest.yaml +1 -0
- package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
- package/templates/api/src/notification/schema.yaml +7 -0
- package/templates/api/src/notification/seed.sql +5 -4
- package/templates/api/src/personal-center/profile/api.ts +63 -0
- package/templates/api/src/personal-center/upload/api.ts +1 -1
- package/templates/api/src/system/login-methods/api.ts +1 -1
- package/templates/api/src/system/oplog/api.ts +3 -3
- package/templates/api/src/system/permissions/api.ts +2 -2
- package/templates/api/src/system/roles/api.ts +2 -2
- package/templates/api/src/system/roles/bindings/api.ts +2 -2
- package/templates/api/src/system/settings/api.ts +2 -2
- package/templates/api/src/system/users/api.ts +2 -2
- package/templates/api/src/system/users/forbidden/api.ts +2 -2
- package/templates/api/src/system/users/password/api.ts +2 -2
- package/templates/api/src/system/users/roles/api.ts +2 -2
- package/templates/api/src/web/user-info/api.ts +4 -5
- package/templates/api/tests/auth-session.spec.ts +1 -1
- package/templates/api/tests/auth-throttle.spec.ts +1 -1
- package/templates/api/tests/fixture.ts +8 -16
- package/templates/api/tests/guard.spec.ts +1 -1
- package/templates/api/tests/l1-builder.test.ts +91 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
- package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
- package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
- package/templates/api/tests/query-utils.spec.ts +85 -1
- package/templates/api/tests/rbac-audit.spec.ts +1 -1
- package/templates/api/tests/rbac-contacts.spec.ts +2 -2
- package/templates/api/tests/rbac-guard.spec.ts +1 -1
- package/templates/api/tests/rbac-roles.spec.ts +2 -2
- package/templates/api/tests/rbac-settings.spec.ts +66 -21
- package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
- package/templates/api/tests/rbac-users.spec.ts +1 -1
- package/templates/api/tsconfig.json +8 -0
- package/templates/cdn/README.md +48 -0
- package/templates/env.d.ts +2 -0
- package/templates/global.d.ts +850 -0
- package/templates/init.include.yaml +14 -2
- package/templates/logs/task-events.jsonl +0 -0
- package/templates/openapi.json +580 -23
- package/templates/package.json +2 -1
- package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
- package/templates/web/src/_platform/__client/api.ts +152 -39
- package/templates/web/src/notification/entry.ts +46 -9
- package/templates/web/src/notification/locales/en-US.json +19 -0
- package/templates/web/src/notification/locales/zh-CN.json +19 -0
- package/templates/web/src/notification/pages/center/index.tsx +131 -0
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
- package/templates/web/src/oj-market/api/client.ts +5 -105
- package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
- package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
- package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
- package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
- package/templates/web/src/oj-market/entry.ts +8 -2
- package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
- package/templates/web/src/oj-market/locales/en-US.json +2 -1
- package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
- package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
- package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
- package/templates/web/src/personal-center/__client/api.ts +25 -1
- package/templates/web/src/personal-center/entry.ts +5 -0
- package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
- package/templates/web/src/system/entry.ts +2 -1
- package/templates/web.config.ts +13 -10
- package/vendor/host-versions.json +3 -3
- package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
- package/templates/api/src/notifications/api.ts +0 -32
- package/templates/api/src/notifications/manifest.yaml +0 -5
- package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
- package/templates/web/src/notifications/__client/api.ts +0 -80
- /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market walkthrough`(T10–T12):在隔离站点上把市场主链路跑一遍,逐项给 AC。
|
|
3
|
+
*
|
|
4
|
+
* 每个 op 的形状固定:setup → act → assert → cleanup(cleanup 在 finally)。`--only <op>` 可单跑,
|
|
5
|
+
* 每个 op 的 setup 自己建立前置态(不依赖前序 op 的残留)——这是「原子」的可测定义。
|
|
6
|
+
*
|
|
7
|
+
* 输出是一张 AC 表 + 汇总行;有 FAIL 即退出码 1。`--strict` 首败即停(调试用)。
|
|
8
|
+
*
|
|
9
|
+
* T12 追加:upgrade / rollback(主链路)与 neg-tamper / nosig / badindex / stranger(负向)。
|
|
10
|
+
* neg-* 统一用 9.9.9 版本与主链路隔离;篡改的文件在 finally 里回抄原始字节(不重签),
|
|
11
|
+
* 收尾摘包重铺索引——否则后续 op 全挂。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TaskRecord } from "../site";
|
|
15
|
+
import type { IsolatedSite } from "./bootstrap";
|
|
16
|
+
import { Buffer } from "node:buffer";
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import process from "node:process";
|
|
22
|
+
import { signCommand } from "../sign";
|
|
23
|
+
import { callApi, login, pollTask, unwrapEnvelope } from "../site";
|
|
24
|
+
import { bootstrapIsolatedSite, derivePublisherKey } from "./bootstrap";
|
|
25
|
+
import { buildIndex } from "./build-index";
|
|
26
|
+
import { FAILURE_PLAYBOOK } from "./failure-playbook";
|
|
27
|
+
import { buildEvilZipSlipTgz, parseFixtureSpec } from "./fixture";
|
|
28
|
+
import { publishPackage } from "./publish";
|
|
29
|
+
import { writeWalkthroughReport } from "./report";
|
|
30
|
+
import { setupMarketUrl } from "./setup";
|
|
31
|
+
|
|
32
|
+
interface Ctx {
|
|
33
|
+
site: IsolatedSite
|
|
34
|
+
base: string
|
|
35
|
+
token: string
|
|
36
|
+
name: string
|
|
37
|
+
version: string
|
|
38
|
+
log: (line: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface OpResult {
|
|
42
|
+
ok: boolean
|
|
43
|
+
detail: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Op {
|
|
47
|
+
/** 命令里 --only 用的名字 */
|
|
48
|
+
key: string
|
|
49
|
+
title: string
|
|
50
|
+
run: (ctx: Ctx) => Promise<OpResult>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WalkthroughOptions {
|
|
54
|
+
projectRoot?: string
|
|
55
|
+
fixture?: string
|
|
56
|
+
/** 只跑某个 op */
|
|
57
|
+
only?: string
|
|
58
|
+
/** 保留隔离现场 */
|
|
59
|
+
keep?: boolean
|
|
60
|
+
/** 首败即停 */
|
|
61
|
+
strict?: boolean
|
|
62
|
+
onLog?: (line: string) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WalkthroughResult {
|
|
66
|
+
ok: boolean
|
|
67
|
+
rows: Array<{ key: string, title: string, ok: boolean, detail: string }>
|
|
68
|
+
port: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** neg-* 专用版本:与主链路(0.1.0/0.2.0)完全隔离,收尾整包摘除 */
|
|
72
|
+
const NEG_VERSION = "9.9.9";
|
|
73
|
+
|
|
74
|
+
const OPS: Op[] = [
|
|
75
|
+
{
|
|
76
|
+
key: "index",
|
|
77
|
+
title: "市场索引出卡片",
|
|
78
|
+
run: async (ctx) => {
|
|
79
|
+
const rows = await callApi<Array<Record<string, unknown>>>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/index");
|
|
80
|
+
const hit = (rows ?? []).find(r => r.name === ctx.name);
|
|
81
|
+
return hit === undefined
|
|
82
|
+
? { ok: false, detail: `索引里没有 ${ctx.name}(拿到 ${(rows ?? []).length} 张卡)` }
|
|
83
|
+
: { ok: true, detail: `${ctx.name} 在列,latest=${String(hit.latest)},available=${JSON.stringify(hit.available ?? [])}` };
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: "install",
|
|
88
|
+
title: "安装(11 段)",
|
|
89
|
+
run: async (ctx) => {
|
|
90
|
+
const task = await install(ctx, ctx.version);
|
|
91
|
+
const done = task.status === "done";
|
|
92
|
+
return { ok: done, detail: done ? `${task.stages.length} 段全 ok` : `status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: "reinstall",
|
|
97
|
+
title: "同版本重装走重装路径",
|
|
98
|
+
run: async (ctx) => {
|
|
99
|
+
await ensureInstalled(ctx);
|
|
100
|
+
const task = await install(ctx, ctx.version);
|
|
101
|
+
const ok = task.status === "done";
|
|
102
|
+
return { ok, detail: ok ? "重装成功(未报方案名占用)" : `status=${task.status}:${task.message}` };
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "enable",
|
|
107
|
+
title: "禁用 / 启用",
|
|
108
|
+
run: async (ctx) => {
|
|
109
|
+
await ensureInstalled(ctx);
|
|
110
|
+
const off = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/enable", { name: ctx.name, enabled: false });
|
|
111
|
+
if (off.ok !== true)
|
|
112
|
+
return { ok: false, detail: `禁用被拒:${off.message ?? "?"}` };
|
|
113
|
+
const on = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/enable", { name: ctx.name, enabled: true });
|
|
114
|
+
return on.ok === true
|
|
115
|
+
? { ok: true, detail: "disable → enable 双向成功" }
|
|
116
|
+
: { ok: false, detail: `启用被拒:${on.message ?? "?"}` };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "verify",
|
|
121
|
+
title: "站点深检",
|
|
122
|
+
run: async (ctx) => {
|
|
123
|
+
await ensureInstalled(ctx);
|
|
124
|
+
const res = await callApi<{ ok?: boolean, checks?: Array<{ id?: string, severity?: string }>, advise?: unknown }>(fetch, ctx.base, ctx.token, "POST", `/api/_platform/solutions/verify?name=${encodeURIComponent(ctx.name)}`);
|
|
125
|
+
// 本 op 断言「深检可用且报告结构完整」,不做裁决。checks 是问题清单:pitfall 10 修复后
|
|
126
|
+
// 全绿站点恒为空数组——空是健康态不是端点异常(此前的非空断言其实寄生在 ledger.stale 误报上)。
|
|
127
|
+
if (typeof res.ok !== "boolean" || !Array.isArray(res.checks) || !Array.isArray(res.advise))
|
|
128
|
+
return { ok: false, detail: "深检报告结构破损(缺 ok/checks/advise 字段)" };
|
|
129
|
+
const errors = res.checks.filter(c => c.severity === "error").map(c => c.id ?? "?");
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
detail: res.checks.length === 0
|
|
133
|
+
? "报告结构完整,checks 为零问题(账本/RBAC/设置/路由/web 全绿)"
|
|
134
|
+
: `报告结构完整(${res.checks.length} 项问题);error 项:${[...new Set(errors)].join("/") || "无"}`,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: "history",
|
|
140
|
+
title: "操作历史可读",
|
|
141
|
+
run: async (ctx) => {
|
|
142
|
+
const rows = await callApi<unknown[]>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/history");
|
|
143
|
+
const n = Array.isArray(rows) ? rows.length : 0;
|
|
144
|
+
return n > 0 ? { ok: true, detail: `历史 ${n} 条` } : { ok: false, detail: "历史为空" };
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: "upgrade",
|
|
149
|
+
title: "升级(0.1.0 → 0.2.0)",
|
|
150
|
+
run: async (ctx) => {
|
|
151
|
+
await ensureInstalled(ctx); // 前置:0.1.0 在列(账本 head=1)
|
|
152
|
+
await publishVersion(ctx, "0.2.0");
|
|
153
|
+
const task = await install(ctx, "0.2.0");
|
|
154
|
+
if (task.status !== "done")
|
|
155
|
+
return { ok: false, detail: `升级安装未 done:status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
|
|
156
|
+
const card = await indexCard(ctx);
|
|
157
|
+
const distDir = path.join(ctx.site.root, "api", "dist");
|
|
158
|
+
const dir010 = path.join(distDir, `${ctx.name}-0.1.0`);
|
|
159
|
+
const dir020 = path.join(distDir, `${ctx.name}-0.2.0`);
|
|
160
|
+
const mig010 = path.join(dir010, "migrations", "0001__init.sql");
|
|
161
|
+
const mig020 = path.join(dir020, "migrations", "0001__init.sql");
|
|
162
|
+
const installedOk = card?.installed === "0.2.0";
|
|
163
|
+
const bothAvailable = JSON.stringify(card?.available ?? []).includes("0.1.0") && JSON.stringify(card?.available ?? []).includes("0.2.0");
|
|
164
|
+
const dirsKept = fs.existsSync(dir010) && fs.existsSync(dir020);
|
|
165
|
+
const migSame = fs.existsSync(mig010) && fs.existsSync(mig020) && fs.readFileSync(mig010).equals(fs.readFileSync(mig020));
|
|
166
|
+
const ok = installedOk && bothAvailable && dirsKept && migSame;
|
|
167
|
+
return {
|
|
168
|
+
ok,
|
|
169
|
+
detail: ok
|
|
170
|
+
? "installed=0.2.0,双版本在列且目录共存(旧版不 prune),0001 迁移逐字节一致"
|
|
171
|
+
: `installedOk=${installedOk} bothAvailable=${bothAvailable} dirsKept=${dirsKept} migSame=${migSame}`,
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
key: "rollback",
|
|
177
|
+
title: "回滚拦截(账本超前,OJ-9)",
|
|
178
|
+
run: async (ctx) => {
|
|
179
|
+
// 前置:0.2.0 已装(账本 head=2)。--only 单跑时自建(upgrade op 不依赖前序残留,此处同纪律)。
|
|
180
|
+
let card = await indexCard(ctx);
|
|
181
|
+
if (card?.installed !== "0.2.0") {
|
|
182
|
+
await publishVersion(ctx, "0.2.0");
|
|
183
|
+
const pre = await install(ctx, "0.2.0");
|
|
184
|
+
if (pre.status !== "done")
|
|
185
|
+
return { ok: false, detail: `前置安装 0.2.0 未 done:status=${pre.status}:${pre.message}` };
|
|
186
|
+
card = await indexCard(ctx);
|
|
187
|
+
}
|
|
188
|
+
const distDir = path.join(ctx.site.root, "api", "dist");
|
|
189
|
+
const before = fs.readdirSync(distDir).sort(); // 零副作用基线:0.1.0 目录是前序合法安装的产物,不代表本次落盘
|
|
190
|
+
const task = await installOnce(ctx, "0.1.0"); // 包内最大 seq 1 < head 2
|
|
191
|
+
const blocked = task.status === "failed" && task.stage === "migrating";
|
|
192
|
+
const untouched = JSON.stringify(fs.readdirSync(distDir).sort()) === JSON.stringify(before);
|
|
193
|
+
const kept = card?.installed === "0.2.0";
|
|
194
|
+
const ok = blocked && untouched && kept;
|
|
195
|
+
return {
|
|
196
|
+
ok,
|
|
197
|
+
detail: ok
|
|
198
|
+
? "failed@migrating(宿主层/oj migrate 双层拦截之一),dist 内容零变化,installed 仍 0.2.0"
|
|
199
|
+
: `blocked=${blocked}(status=${task.status} stage=${task.stage})untouched=${untouched} kept=${kept}:${task.message}`,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
key: "uninstall",
|
|
205
|
+
title: "卸载清场",
|
|
206
|
+
run: async (ctx) => {
|
|
207
|
+
const card = await indexCard(ctx);
|
|
208
|
+
const installed = typeof card?.installed === "string" && card.installed !== "" ? card.installed : null;
|
|
209
|
+
if (installed === null)
|
|
210
|
+
await ensureInstalled(ctx); // 未装过:先装 ctx.version
|
|
211
|
+
const target = installed ?? ctx.version;
|
|
212
|
+
const res = await callApi<{ ok?: boolean, message?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/uninstall", { name: ctx.name });
|
|
213
|
+
if (res.ok !== true)
|
|
214
|
+
return { ok: false, detail: `卸载被拒:${res.message ?? "?"}` };
|
|
215
|
+
const dir = path.join(ctx.site.root, "api", "dist", `${ctx.name}-${target}`);
|
|
216
|
+
return fs.existsSync(dir)
|
|
217
|
+
? { ok: false, detail: `卸载后目录仍在:${dir}` }
|
|
218
|
+
: { ok: true, detail: `${ctx.name}@${target} 锁 / 目录 / 注册面清场` };
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
key: "neg-tamper",
|
|
223
|
+
title: "负向:包体篡改(sha256 不符)",
|
|
224
|
+
run: async (ctx) => {
|
|
225
|
+
await publishVersion(ctx, NEG_VERSION);
|
|
226
|
+
const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
|
|
227
|
+
const orig = fs.readFileSync(tgz);
|
|
228
|
+
try {
|
|
229
|
+
const tampered = Buffer.from(orig);
|
|
230
|
+
tampered[Math.floor(tampered.length / 2)] ^= 0x01; // 翻转中间一个字节
|
|
231
|
+
fs.writeFileSync(tgz, tampered);
|
|
232
|
+
const task = await installOnce(ctx, NEG_VERSION);
|
|
233
|
+
const rejected = task.status === "failed" && task.stage === "verifying";
|
|
234
|
+
const noDrop = !fs.existsSync(path.join(ctx.site.root, "api", "dist", `${ctx.name}-${NEG_VERSION}`));
|
|
235
|
+
const ok = rejected && noDrop;
|
|
236
|
+
return {
|
|
237
|
+
ok,
|
|
238
|
+
detail: ok
|
|
239
|
+
? "failed@verifying(sha256 与索引不符,C2-29 fail-closed),零落盘"
|
|
240
|
+
: `status=${task.status} stage=${task.stage} noDrop=${noDrop}:${task.message}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
|
|
245
|
+
await removeVersion(ctx, NEG_VERSION);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
key: "neg-nosig",
|
|
251
|
+
title: "负向:缺包级签名(sig_missing)",
|
|
252
|
+
run: async (ctx) => {
|
|
253
|
+
await publishVersion(ctx, NEG_VERSION);
|
|
254
|
+
const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
|
|
255
|
+
const sig = `${tgz}.sig`;
|
|
256
|
+
const origSig = fs.readFileSync(sig);
|
|
257
|
+
try {
|
|
258
|
+
fs.rmSync(sig);
|
|
259
|
+
await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem }); // Form B 缺 archive.signature
|
|
260
|
+
const task = await installOnce(ctx, NEG_VERSION);
|
|
261
|
+
const reason = task.error?.reason ?? task.message;
|
|
262
|
+
const ok = task.status === "failed" && reason.includes("sig_missing");
|
|
263
|
+
return { ok, detail: ok ? "failed,失败码 sig_missing(G4 硬 fail-closed)" : `status=${task.status}:${reason}` };
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
fs.writeFileSync(sig, origSig); // 回抄原始字节
|
|
267
|
+
await removeVersion(ctx, NEG_VERSION);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
key: "neg-badindex",
|
|
273
|
+
title: "负向:索引篡改(验签拒载)",
|
|
274
|
+
run: async (ctx) => {
|
|
275
|
+
await publishVersion(ctx, NEG_VERSION);
|
|
276
|
+
const bundles = path.join(ctx.site.cdnDir, "bundles.yaml");
|
|
277
|
+
const orig = fs.readFileSync(bundles);
|
|
278
|
+
try {
|
|
279
|
+
fs.appendFileSync(bundles, "# tampered\n"); // 索引字节变、签名失配
|
|
280
|
+
let msg = "";
|
|
281
|
+
try {
|
|
282
|
+
await installOnce(ctx, NEG_VERSION);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
msg = (error as Error).message; // 预期:安装 POST 即 502,任务都未创建
|
|
286
|
+
}
|
|
287
|
+
const ok = msg.includes("签名校验失败");
|
|
288
|
+
return { ok, detail: ok ? "安装 POST 即 502 拒载(G4 fail-closed),任务未创建" : `未按预期拒装:${msg === "" ? "安装竟成功" : msg}` };
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
fs.writeFileSync(bundles, orig); // 回抄原始字节
|
|
292
|
+
await removeVersion(ctx, NEG_VERSION);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
key: "neg-stranger",
|
|
298
|
+
title: "负向:未登记 key(TOFU 闭环)",
|
|
299
|
+
run: async (ctx) => {
|
|
300
|
+
await publishVersion(ctx, NEG_VERSION);
|
|
301
|
+
const tgz = path.join(ctx.site.cdnDir, `${ctx.name}-${NEG_VERSION}.tgz`);
|
|
302
|
+
const sig = `${tgz}.sig`;
|
|
303
|
+
const orig = fs.readFileSync(tgz);
|
|
304
|
+
const origSig = fs.readFileSync(sig);
|
|
305
|
+
const stranger = derivePublisherKey(randomBytes(16).toString("hex")); // 未登记身份,私钥只在内存
|
|
306
|
+
const before = await trustKeys(ctx); // 信任库快照(F3 diff 基线)
|
|
307
|
+
try {
|
|
308
|
+
signCommand(tgz, { id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem }); // 覆盖侧车签名
|
|
309
|
+
await buildIndex({ projectRoot: ctx.site.root, id: "stranger.publisher", privateKeyPem: stranger.privateKeyPem });
|
|
310
|
+
const first = await installOnce(ctx, NEG_VERSION);
|
|
311
|
+
if (first.status !== "needs_trust")
|
|
312
|
+
return { ok: false, detail: `未停 needs_trust:status=${first.status}:${first.message}` };
|
|
313
|
+
await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: first.id });
|
|
314
|
+
const second = await installOnce(ctx, NEG_VERSION);
|
|
315
|
+
if (second.status !== "done")
|
|
316
|
+
return { ok: false, detail: `确认后重装未 done:status=${second.status}:${second.message}` };
|
|
317
|
+
// 收尾(F3):显式 revoke 陌生 key,快照 diff 断言无残留——不靠 uninstall 兜底(它本就不摘信任)
|
|
318
|
+
const del = await callApi<{ revoked?: boolean }>(fetch, ctx.base, ctx.token, "DELETE", `/api/_platform/market/trust/key?key_id=${encodeURIComponent("stranger.publisher")}`);
|
|
319
|
+
const after = await trustKeys(ctx);
|
|
320
|
+
const revoked = del.revoked === true;
|
|
321
|
+
const noResidue = JSON.stringify(after) === JSON.stringify(before);
|
|
322
|
+
const ok = revoked && noResidue;
|
|
323
|
+
return {
|
|
324
|
+
ok,
|
|
325
|
+
detail: ok
|
|
326
|
+
? "needs_trust → confirm → done;stranger key 已 revoke,信任库快照 diff 为空"
|
|
327
|
+
: `revoked=${revoked} noResidue=${noResidue}(before=${before.length} after=${after.length})`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
fs.writeFileSync(tgz, orig); // 回抄原始字节,不重签
|
|
332
|
+
fs.writeFileSync(sig, origSig);
|
|
333
|
+
await removeVersion(ctx, NEG_VERSION);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
key: "neg-evil",
|
|
339
|
+
title: "负向:zip-slip 越界包(R1 拒收)",
|
|
340
|
+
run: async (ctx) => {
|
|
341
|
+
const evil = path.join(ctx.site.cdnDir, `${ctx.name}-evil.tgz`);
|
|
342
|
+
buildEvilZipSlipTgz(evil); // 成员含 ../evil.sql,无需发布进索引(file 源直传)
|
|
343
|
+
const distDir = path.join(ctx.site.root, "api", "dist");
|
|
344
|
+
const before = fs.readdirSync(distDir).sort(); // 零副作用基线:9.9.9 目录可能是前序 neg-stranger 的合法产物
|
|
345
|
+
try {
|
|
346
|
+
let msg = "";
|
|
347
|
+
try {
|
|
348
|
+
await installFile(ctx, evil, NEG_VERSION);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
msg = (error as Error).message; // 预期:上传校验 400,任务未创建
|
|
352
|
+
}
|
|
353
|
+
const rejected = msg.includes("zip-slip");
|
|
354
|
+
const untouched = JSON.stringify(fs.readdirSync(distDir).sort()) === JSON.stringify(before);
|
|
355
|
+
const ok = rejected && untouched;
|
|
356
|
+
return {
|
|
357
|
+
ok,
|
|
358
|
+
detail: ok
|
|
359
|
+
? "上传即 400(R1 zip-slip 防护拒在解包前),dist 内容零变化"
|
|
360
|
+
: `rejected=${rejected} untouched=${untouched}:${msg === "" ? "安装竟成功" : msg}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
fs.rmSync(evil, { force: true });
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
export async function walkthrough(opts: WalkthroughOptions = {}): Promise<WalkthroughResult> {
|
|
371
|
+
const log = opts.onLog ?? ((line: string) => console.log(line));
|
|
372
|
+
const spec = parseFixtureSpec(opts.fixture);
|
|
373
|
+
const site = await bootstrapIsolatedSite({ projectRoot: opts.projectRoot, fixture: opts.fixture, keep: opts.keep, onLog: log });
|
|
374
|
+
try {
|
|
375
|
+
const newPass = "trial-pass-1";
|
|
376
|
+
await setupMarketUrl({ projectRoot: site.root, url: site.baseUrl, user: site.admin.user, pass: site.admin.pass, newPass });
|
|
377
|
+
const token = await login(fetch, site.baseUrl, site.admin.user, newPass);
|
|
378
|
+
const ctx: Ctx = { site, base: site.baseUrl, token, name: spec.name, version: spec.version, log };
|
|
379
|
+
|
|
380
|
+
const selected = opts.only === undefined ? OPS : OPS.filter(op => op.key === opts.only);
|
|
381
|
+
if (selected.length === 0)
|
|
382
|
+
throw new Error(`[ojm] 未知 op「${opts.only}」——可用:${OPS.map(op => op.key).join(" / ")}`);
|
|
383
|
+
|
|
384
|
+
const rows: WalkthroughResult["rows"] = [];
|
|
385
|
+
for (const op of selected) {
|
|
386
|
+
let row: WalkthroughResult["rows"][number];
|
|
387
|
+
try {
|
|
388
|
+
const result = await op.run(ctx);
|
|
389
|
+
row = { key: op.key, title: op.title, ok: result.ok, detail: result.detail };
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
row = { key: op.key, title: op.title, ok: false, detail: (error as Error).message };
|
|
393
|
+
}
|
|
394
|
+
rows.push(row);
|
|
395
|
+
log(`${row.ok ? "✓" : "✗"} ${op.key.padEnd(10)} ${op.title}${row.ok ? "" : ` —— ${row.detail}`}`);
|
|
396
|
+
if (!row.ok && opts.strict === true)
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const failed = rows.filter(r => !r.ok).length;
|
|
401
|
+
log(`\n${failed === 0 ? "全部通过" : `${failed} 项未过`}(${rows.length} 项):${rows.filter(r => r.ok).length} PASS / ${failed} FAIL`);
|
|
402
|
+
// 报告落盘(白名单内容 + 黑名单脱敏兜底):AC 表 + 汇总 + 失败文案表
|
|
403
|
+
const reportPath = writeWalkthroughReport({ rows, port: site.port, playbook: FAILURE_PLAYBOOK });
|
|
404
|
+
log(`[ojm] 报告已落盘 → ${reportPath}`);
|
|
405
|
+
return { ok: failed === 0, rows, port: site.port };
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
await site.cleanup();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 安装 + 轮询;needs_trust 时自动确认(隔离站点,无外部信任可言) */
|
|
413
|
+
async function install(ctx: Ctx, version: string): Promise<TaskRecord> {
|
|
414
|
+
let task = await installOnce(ctx, version);
|
|
415
|
+
if (task.status === "needs_trust") {
|
|
416
|
+
await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: task.id });
|
|
417
|
+
task = await installOnce(ctx, version);
|
|
418
|
+
}
|
|
419
|
+
return task;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function installOnce(ctx: Ctx, version: string): Promise<TaskRecord> {
|
|
423
|
+
const started = await callApi<{ task_id?: string, id?: string }>(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/install", { name: ctx.name, version, source: "cdn" });
|
|
424
|
+
const taskId = String(started.task_id ?? started.id ?? "");
|
|
425
|
+
if (taskId === "")
|
|
426
|
+
throw new Error("[ojm] 安装接口未返回 task id");
|
|
427
|
+
return pollTask(fetch, ctx.base, ctx.token, taskId, { interval0: 400, intervalMax: 2000, timeoutMs: 120_000 });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** file 源 multipart 上传(与 install.ts 同形态;neg-evil 用它递 zip-slip 恶意包) */
|
|
431
|
+
async function installFile(ctx: Ctx, tgzPath: string, version: string): Promise<void> {
|
|
432
|
+
const form = new FormData();
|
|
433
|
+
form.append("name", ctx.name);
|
|
434
|
+
form.append("version", version);
|
|
435
|
+
form.append("source", "file");
|
|
436
|
+
form.append("file", new Blob([fs.readFileSync(tgzPath)], { type: "application/gzip" }), path.basename(tgzPath));
|
|
437
|
+
const res = await fetch(`${ctx.base}/api/_platform/market/install`, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: { authorization: `Bearer ${ctx.token}` },
|
|
440
|
+
body: form,
|
|
441
|
+
});
|
|
442
|
+
await unwrapEnvelope(res); // 非 0 code 抛人话(400 zip-slip / 502 索引等)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 信任库 key_id 快照(GET /market/trust;neg-stranger 的 diff 断言用) */
|
|
446
|
+
async function trustKeys(ctx: Ctx): Promise<string[]> {
|
|
447
|
+
const res = await callApi<{ keys?: Array<{ key_id?: string }> }>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/trust");
|
|
448
|
+
return (res.keys ?? []).map(k => k.key_id ?? "").filter(id => id !== "").sort();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 前置态:保证目标版本已装(其他 op 不依赖前序 op 的残留) */
|
|
452
|
+
async function ensureInstalled(ctx: Ctx): Promise<void> {
|
|
453
|
+
const card = await indexCard(ctx);
|
|
454
|
+
if (card?.installed === ctx.version)
|
|
455
|
+
return;
|
|
456
|
+
const task = await install(ctx, ctx.version);
|
|
457
|
+
if (task.status !== "done")
|
|
458
|
+
throw new Error(`前置安装失败:status=${task.status}:${task.message}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 索引卡片(本方案一行;upgrade/rollback/uninstall 与 index op 同一消费面) */
|
|
462
|
+
async function indexCard(ctx: Ctx): Promise<Record<string, unknown> | undefined> {
|
|
463
|
+
const rows = await callApi<Array<Record<string, unknown>>>(fetch, ctx.base, ctx.token, "GET", "/api/_platform/market/index");
|
|
464
|
+
return (rows ?? []).find(r => r.name === ctx.name);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** 发布某版本 fixture 进隔离 CDN 并重铺索引(upgrade/rollback/neg-* 的共用前置) */
|
|
468
|
+
async function publishVersion(ctx: Ctx, version: string): Promise<void> {
|
|
469
|
+
await publishPackage({ projectRoot: ctx.site.root, fixture: `${ctx.name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
|
|
470
|
+
await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** neg op 收尾:整包摘除(tgz + 侧车签名)并重铺索引,恢复主链路原状 */
|
|
474
|
+
async function removeVersion(ctx: Ctx, version: string): Promise<void> {
|
|
475
|
+
for (const file of [`${ctx.name}-${version}.tgz`, `${ctx.name}-${version}.tgz.sig`])
|
|
476
|
+
fs.rmSync(path.join(ctx.site.cdnDir, file), { force: true });
|
|
477
|
+
await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export const WALKTHROUGH_OPS = OPS.map(op => op.key);
|
|
481
|
+
|
|
482
|
+
export async function walkthroughCommand(argv: string[]): Promise<void> {
|
|
483
|
+
const opts: WalkthroughOptions = {};
|
|
484
|
+
for (let i = 0; i < argv.length; i++) {
|
|
485
|
+
const a = argv[i]!;
|
|
486
|
+
if (a === "--keep") {
|
|
487
|
+
opts.keep = true;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (a === "--strict") {
|
|
491
|
+
opts.strict = true;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (a === "--only" || a === "--fixture") {
|
|
495
|
+
const v = argv[i + 1] ?? "";
|
|
496
|
+
i++;
|
|
497
|
+
if (a === "--only")
|
|
498
|
+
opts.only = v;
|
|
499
|
+
else
|
|
500
|
+
opts.fixture = v;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const result = await walkthrough(opts);
|
|
504
|
+
if (!result.ok)
|
|
505
|
+
process.exitCode = 1;
|
|
506
|
+
}
|
package/src/oj.ts
CHANGED
|
@@ -113,7 +113,9 @@ export function startOj(
|
|
|
113
113
|
});
|
|
114
114
|
// spawn 本身失败(如 ENOENT)只发 error 不发 exit,必须单独兜住
|
|
115
115
|
child.once("error", (err) => {
|
|
116
|
-
|
|
116
|
+
const enoent = (err as NodeJS.ErrnoException).code === "ENOENT";
|
|
117
|
+
const hint = enoent ? "\n下一步:oj 二进制尚未安装(它在 .gitignore 里,不随仓库分发)。\n在本工程目录跑一次 `ojm vendor`(经 npm 包 @oj-bin/oj 下载),再重试本命令。" : "";
|
|
118
|
+
finish(new Error(`[ojm] 无法启动 oj(${binPath}):${err.message}${hint}`));
|
|
117
119
|
});
|
|
118
120
|
const timeout = setTimeout(() => {
|
|
119
121
|
finish(new Error(
|