@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
|
@@ -8,7 +8,23 @@ import { join } from "node:path";
|
|
|
8
8
|
import { gunzipSync } from "node:zlib";
|
|
9
9
|
import { describe, expect, it } from "vitest";
|
|
10
10
|
|
|
11
|
-
import { buildEvilZipSlipTgz, buildFixturePackage } from "../fixture";
|
|
11
|
+
import { buildEvilZipSlipTgz, buildFixturePackage, parseFixtureSpec } from "../fixture";
|
|
12
|
+
|
|
13
|
+
describe("given --fixture 规格含路径穿越 When 解析", () => {
|
|
14
|
+
it("then 含 .. 的 name 被拒(fail-closed,杜绝 cdn/staging 目录逃逸)", () => {
|
|
15
|
+
expect(() => parseFixtureSpec("../evil@1.0.0")).toThrow();
|
|
16
|
+
expect(() => parseFixtureSpec("a/../b@1.0.0")).toThrow();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("then 含路径分隔符的 version 被拒", () => {
|
|
20
|
+
expect(() => parseFixtureSpec("demo@1.0.0/../../x")).toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("then 合法 slug/版本仍可解析,缺省补齐", () => {
|
|
24
|
+
expect(parseFixtureSpec("my-solution@0.2.0")).toEqual({ name: "my-solution", version: "0.2.0" });
|
|
25
|
+
expect(parseFixtureSpec()).toEqual({ name: "demo-solution", version: "0.1.0" });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
12
28
|
|
|
13
29
|
describe("given 合成 fixture 方案树 When 看 migrations 目录", () => {
|
|
14
30
|
it("then 0.1.0 只有 0001(head 上限 1)", () => {
|
|
@@ -9,12 +9,13 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { describe, expect, it } from "vitest";
|
|
11
11
|
|
|
12
|
-
import { verifyCanonical } from "../../lifecycle/sign";
|
|
12
|
+
import { CANON, verifyCanonical } from "../../lifecycle/sign";
|
|
13
13
|
import { publishPackage } from "../publish";
|
|
14
14
|
|
|
15
15
|
const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
|
|
16
16
|
const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
|
|
17
17
|
const KEY_ID = "demo.publisher";
|
|
18
|
+
const REGISTRY_ID = "test.registry";
|
|
18
19
|
|
|
19
20
|
/** Ed25519 测试身份:私钥只在内存,路径从不落盘 */
|
|
20
21
|
function testIdentity(): { privateKeyPem: string, publicKeyHex: string } {
|
|
@@ -38,7 +39,7 @@ describe("given cdn/ 为空 When 发布 fixture", () => {
|
|
|
38
39
|
const root = newProject();
|
|
39
40
|
const identity = testIdentity();
|
|
40
41
|
|
|
41
|
-
const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
|
|
42
|
+
const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem, registryId: REGISTRY_ID });
|
|
42
43
|
|
|
43
44
|
expect(existsSync(result.tgzPath)).toBe(true);
|
|
44
45
|
expect(existsSync(result.sigPath)).toBe(true);
|
|
@@ -51,12 +52,12 @@ describe("given cdn/ 为空 When 发布 fixture", () => {
|
|
|
51
52
|
const root = newProject();
|
|
52
53
|
const identity = testIdentity();
|
|
53
54
|
|
|
54
|
-
const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
|
|
55
|
+
const result = await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem, registryId: REGISTRY_ID });
|
|
55
56
|
const sig = JSON.parse(readFileSync(result.sigPath, "utf-8")) as { key_id: string, public_key: string, value: string, canon: string };
|
|
56
57
|
|
|
57
58
|
expect(sig.key_id).toBe(KEY_ID);
|
|
58
59
|
expect(sig.public_key).toBe(identity.publicKeyHex);
|
|
59
|
-
expect(sig.canon).toBe(
|
|
60
|
+
expect(sig.canon).toBe(CANON);
|
|
60
61
|
expect(verifyCanonical(sig.public_key, result.canonical, sig.value)).toBe(true);
|
|
61
62
|
});
|
|
62
63
|
|
|
@@ -64,7 +65,7 @@ describe("given cdn/ 为空 When 发布 fixture", () => {
|
|
|
64
65
|
const root = newProject();
|
|
65
66
|
const identity = testIdentity();
|
|
66
67
|
|
|
67
|
-
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem });
|
|
68
|
+
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem, registryId: REGISTRY_ID });
|
|
68
69
|
|
|
69
70
|
const pemInCdn = readdirSync(join(root, "cdn")).filter(f => f.endsWith(".pem"));
|
|
70
71
|
expect(pemInCdn).toEqual([]);
|
|
@@ -75,7 +76,7 @@ describe("given 同 name@version 再次发布", () => {
|
|
|
75
76
|
it("when 内容一致 Then 幂等覆盖(sha256 不变)", async () => {
|
|
76
77
|
const root = newProject();
|
|
77
78
|
const identity = testIdentity();
|
|
78
|
-
const base = { projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem };
|
|
79
|
+
const base = { projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem, registryId: REGISTRY_ID };
|
|
79
80
|
|
|
80
81
|
const first = await publishPackage(base);
|
|
81
82
|
const second = await publishPackage(base);
|
|
@@ -90,7 +91,7 @@ describe("given 同 name@version 再次发布", () => {
|
|
|
90
91
|
const target = join(root, "cdn", "demo-solution-0.1.0.tgz");
|
|
91
92
|
writeFileSync(target, "not-a-real-tgz-but-different-bytes", "utf-8");
|
|
92
93
|
|
|
93
|
-
await expect(publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem }))
|
|
94
|
+
await expect(publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: identity.privateKeyPem, registryId: REGISTRY_ID }))
|
|
94
95
|
.rejects
|
|
95
96
|
.toThrow(/version/);
|
|
96
97
|
});
|
|
@@ -62,7 +62,7 @@ describe("given 站点可直连且凭据未悬挂 When setup", () => {
|
|
|
62
62
|
const root = newProject();
|
|
63
63
|
const site = fakeSite();
|
|
64
64
|
|
|
65
|
-
const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456",
|
|
65
|
+
const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", siteUrl: "http://127.0.0.1:9778", fetchFn: site.fetchFn });
|
|
66
66
|
|
|
67
67
|
expect(result.cdnBaseUrl).toBe("http://127.0.0.1:9778/cdn");
|
|
68
68
|
const put = site.calls.find(c => c.url.endsWith("/api/system/settings"))!;
|
|
@@ -77,7 +77,7 @@ describe("given seed admin 处于 must_change 悬挂期 When setup", () => {
|
|
|
77
77
|
const root = newProject();
|
|
78
78
|
const site = fakeSite({ restricted: true });
|
|
79
79
|
|
|
80
|
-
const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123",
|
|
80
|
+
const result = await setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123", siteUrl: "http://127.0.0.1:9778", fetchFn: site.fetchFn });
|
|
81
81
|
|
|
82
82
|
expect(result.changedPassword).toBe(true);
|
|
83
83
|
const pwd = site.calls.find(c => c.url.endsWith("/api/auth/password"))!;
|
|
@@ -96,7 +96,7 @@ describe("given 站点不可达 When setup", () => {
|
|
|
96
96
|
};
|
|
97
97
|
const fetchFn = failing as unknown as typeof fetch;
|
|
98
98
|
|
|
99
|
-
await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456",
|
|
99
|
+
await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", siteUrl: "http://127.0.0.1:9778", fetchFn }))
|
|
100
100
|
.rejects
|
|
101
101
|
.toThrow(/ojm dev/);
|
|
102
102
|
});
|
|
@@ -107,7 +107,7 @@ describe("given 改密后写设置仍被拒 When setup", () => {
|
|
|
107
107
|
const root = newProject();
|
|
108
108
|
const site = fakeSite({ restricted: true, rejectSettings: true });
|
|
109
109
|
|
|
110
|
-
await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123",
|
|
110
|
+
await expect(setupMarketUrl({ projectRoot: root, user: "admin", pass: "123456", newPass: "newpass-123", siteUrl: "http://127.0.0.1:9778", fetchFn: site.fetchFn }))
|
|
111
111
|
.rejects
|
|
112
112
|
.toThrow(/must_change|权限|设置/);
|
|
113
113
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { BuiltSolution } from "../index-builder";
|
|
2
|
+
// packShards spec(REG-CS5 / D3):装箱策略——整包装箱、累加到上限开新片、超限包才跨连续片。
|
|
3
|
+
// 钉死「索引只取决于目录内容」:同样输入永远同样输出(编号由 build-index 按内容匹配分配,
|
|
4
|
+
// 这里只断言槽位序列与片号映射)。
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { packShards, SHARD_MAX_VERSIONS } from "../sharding";
|
|
7
|
+
|
|
8
|
+
function solution(name: string, count: number): BuiltSolution {
|
|
9
|
+
return {
|
|
10
|
+
name,
|
|
11
|
+
latest: `${name}-${count}`,
|
|
12
|
+
versions: Array.from({ length: count }, (_, i) => ({ name, version: `0.${i}.0`, archive: { file: `${name}-0.${i}.0.tgz` } })),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function versionsOf(slots: ReturnType<typeof packShards>["slots"], no: number): number {
|
|
17
|
+
return slots[no - 1]!.versions;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("given 一批方案 When 装箱", () => {
|
|
21
|
+
it("then 整包入片、版本数累加到上限才开新片(1000 条一片)", () => {
|
|
22
|
+
const packed = packShards([solution("a", 600), solution("b", 400)]);
|
|
23
|
+
expect(packed.slots).toHaveLength(1);
|
|
24
|
+
expect(packed.slots[0]!.solutions.map(s => s.name)).toEqual(["a", "b"]);
|
|
25
|
+
expect(versionsOf(packed.slots, 1)).toBe(SHARD_MAX_VERSIONS);
|
|
26
|
+
expect(packed.assignment.get("a")).toEqual([1]);
|
|
27
|
+
expect(packed.assignment.get("b")).toEqual([1]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("then 放不下就开新片:已有包不换片,只把放不下的挤到新片", () => {
|
|
31
|
+
const packed = packShards([solution("a", 600), solution("b", 500)]);
|
|
32
|
+
expect(packed.slots).toHaveLength(2);
|
|
33
|
+
expect(versionsOf(packed.slots, 1)).toBe(600);
|
|
34
|
+
expect(versionsOf(packed.slots, 2)).toBe(500);
|
|
35
|
+
expect(packed.assignment.get("a")).toEqual([1]);
|
|
36
|
+
expect(packed.assignment.get("b")).toEqual([2]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("then 空索引也产一个空片(catalog 的 shards 映射不为空,读方口径统一)", () => {
|
|
40
|
+
const packed = packShards([]);
|
|
41
|
+
expect(packed.slots).toHaveLength(1);
|
|
42
|
+
expect(packed.slots[0]!.solutions).toEqual([]);
|
|
43
|
+
expect(packed.assignment.size).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("then 确定性:同输入两次装箱,槽位逐字节同构", () => {
|
|
47
|
+
const input = [solution("a", 3), solution("b", 2), solution("c", 1)];
|
|
48
|
+
expect(JSON.stringify(packShards(input))).toBe(JSON.stringify(packShards(input)));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("given 2500 个版本、1 个包 When 装箱(S2-1 BDD)", () => {
|
|
53
|
+
it("then 生成 3 个 shard 且每片 ≤1000 条,catalog 可指向该包所在三片", () => {
|
|
54
|
+
const packed = packShards([solution("mega", 2500)]);
|
|
55
|
+
expect(packed.slots).toHaveLength(3);
|
|
56
|
+
expect(versionsOf(packed.slots, 1)).toBe(1000);
|
|
57
|
+
expect(versionsOf(packed.slots, 2)).toBe(1000);
|
|
58
|
+
expect(versionsOf(packed.slots, 3)).toBe(500);
|
|
59
|
+
expect(packed.assignment.get("mega")).toEqual([1, 2, 3]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("then 最高片装最新版:latest 落在最后一片,安装只请求 1 个 shard", () => {
|
|
63
|
+
const packed = packShards([solution("mega", 2500)]);
|
|
64
|
+
const last = packed.slots[2]!;
|
|
65
|
+
expect(last.solutions[0]!.versions.at(-1)!.version).toBe("0.2499.0");
|
|
66
|
+
expect(last.solutions[0]!.latest).toBe("mega-2500");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("given 一个包自身超限 When 装箱", () => {
|
|
71
|
+
it("then 按连续上限切片、占连续片号,片内仍是完整方案条目", () => {
|
|
72
|
+
const packed = packShards([solution("mega", SHARD_MAX_VERSIONS * 2 + 7), solution("small", 3)]);
|
|
73
|
+
expect(packed.slots).toHaveLength(4);
|
|
74
|
+
expect(packed.assignment.get("mega")).toEqual([1, 2, 3]);
|
|
75
|
+
expect(packed.assignment.get("small")).toEqual([4]);
|
|
76
|
+
for (const slot of packed.slots.slice(0, 3)) {
|
|
77
|
+
expect(slot.solutions).toHaveLength(1);
|
|
78
|
+
expect(slot.solutions[0]!.name).toBe("mega");
|
|
79
|
+
expect(slot.versions).toBeLessThanOrEqual(SHARD_MAX_VERSIONS);
|
|
80
|
+
}
|
|
81
|
+
expect(versionsOf(packed.slots, 3)).toBe(7);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("then 超限包前后的普通包互不挤占", () => {
|
|
85
|
+
const packed = packShards([solution("before", 10), solution("mega", SHARD_MAX_VERSIONS * 2 + 1), solution("after", 10)]);
|
|
86
|
+
expect(packed.assignment.get("before")).toEqual([1]);
|
|
87
|
+
expect(packed.assignment.get("mega")).toEqual([2, 3, 4]);
|
|
88
|
+
expect(packed.assignment.get("after")).toEqual([5]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
import type { VerifyCheck } from "../verify";
|
|
2
|
+
// ojm market verify spec(T7 + REG-CS5 三级化):离线验包——不起站点、不装、不要凭据。
|
|
2
3
|
//
|
|
3
4
|
// 钉死两条:① 四类校验分列(完整性 / 包级签 / 索引签 / 结构),篡改任一项都必须在对应项上打叉;
|
|
4
5
|
// ② 结论恒带边界声明——验签过 ≠ 代码无恶意,这一点写错比校验写错更危险。
|
|
6
|
+
//
|
|
7
|
+
// 三级化后的索引面:catalog.yaml(代次真源)+ shards/<n>.yaml(版本明细与 archive.sha256)。
|
|
8
|
+
// 索引验签按文件分列(catalog 一项、每片一项),同代校验单独一项——红队 F2 的中间态
|
|
9
|
+
// (新 catalog 钉旧片)必须在同代项上打叉,且按「运维事故」而不是「被篡改」提示。
|
|
5
10
|
import { Buffer } from "node:buffer";
|
|
6
11
|
import { generateKeyPairSync } from "node:crypto";
|
|
7
12
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
13
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
11
15
|
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import { parse, stringify } from "yaml";
|
|
18
|
+
import { createDeterministicTgz } from "../../deterministic-tar";
|
|
19
|
+
import { recomputeIndex, signIndex } from "../../publish";
|
|
20
|
+
import { signCommand } from "../../sign";
|
|
12
21
|
import { buildIndex } from "../build-index";
|
|
13
22
|
import { publishPackage } from "../publish";
|
|
23
|
+
import { readTgz } from "../scan";
|
|
14
24
|
import { verifyMarket } from "../verify";
|
|
15
25
|
|
|
16
26
|
const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
|
|
17
27
|
const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
|
|
18
28
|
const KEY_ID = "demo.publisher";
|
|
29
|
+
const REGISTRY_ID = "test.registry";
|
|
19
30
|
|
|
20
31
|
function testPem(): string {
|
|
21
32
|
return generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
@@ -32,15 +43,20 @@ function newProject(): string {
|
|
|
32
43
|
/** 落一个包并重建索引,返回工程根 */
|
|
33
44
|
async function seed(): Promise<string> {
|
|
34
45
|
const root = newProject();
|
|
35
|
-
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
|
|
46
|
+
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
|
|
36
47
|
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
37
48
|
return root;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
function itemOf(result: Awaited<ReturnType<typeof verifyMarket>>, name: string):
|
|
51
|
+
function itemOf(result: Awaited<ReturnType<typeof verifyMarket>>, name: string): VerifyCheck | undefined {
|
|
41
52
|
return result.checks.find(c => c.item === name);
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/** 全部「索引签名」项(三级化后按文件分列:catalog 一项 + 每片一项) */
|
|
56
|
+
function sigItems(result: Awaited<ReturnType<typeof verifyMarket>>): VerifyCheck[] {
|
|
57
|
+
return result.checks.filter(c => c.item === "索引签名");
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
describe("given 一个正常落地的包 When 离线验包", () => {
|
|
45
61
|
it("then 四类校验全绿且结论带边界声明", async () => {
|
|
46
62
|
const root = await seed();
|
|
@@ -50,12 +66,57 @@ describe("given 一个正常落地的包 When 离线验包", () => {
|
|
|
50
66
|
expect(result.passed).toBe(true);
|
|
51
67
|
expect(itemOf(result, "完整性")?.ok).toBe(true);
|
|
52
68
|
expect(itemOf(result, "包级签名")?.ok).toBe(true);
|
|
53
|
-
|
|
69
|
+
// 三级索引:catalog 与 1 号片各签各的,两项都在且全绿
|
|
70
|
+
const sigs = sigItems(result);
|
|
71
|
+
expect(sigs.length).toBeGreaterThanOrEqual(2);
|
|
72
|
+
expect(sigs.every(c => c.ok)).toBe(true);
|
|
54
73
|
expect(itemOf(result, "manifest 结构")?.ok).toBe(true);
|
|
55
74
|
expect(result.conclusion).toContain("不构成内容安全性背书");
|
|
56
75
|
});
|
|
57
76
|
});
|
|
58
77
|
|
|
78
|
+
describe("given 真实工程包(manifest 按 C2-29 整体省略 archive 占位)When 离线验包", () => {
|
|
79
|
+
it("then manifest 结构不因此打叉(REG-CS8 回归:ojm build --bundle 不落 archive ≠ 缺必填)", async () => {
|
|
80
|
+
const root = await seed();
|
|
81
|
+
const tgz = join(root, "cdn", "demo-solution-0.1.0.tgz");
|
|
82
|
+
// 解包 → 摘掉根 manifest 的 archive(fixture 有、真实方案无的字段差)→ 确定性重打包 → 重签 → 重建索引
|
|
83
|
+
const staging = mkdtempSync(join(tmpdir(), "ojm-verify-noarchive-"));
|
|
84
|
+
for (const e of readTgz(new Uint8Array(readFileSync(tgz)))) {
|
|
85
|
+
if (e.name.endsWith("/"))
|
|
86
|
+
continue;
|
|
87
|
+
const abs = join(staging, e.name);
|
|
88
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
89
|
+
writeFileSync(abs, e.content);
|
|
90
|
+
}
|
|
91
|
+
const rootManifestPath = join(staging, "manifest.yaml");
|
|
92
|
+
const manifest = parse(readFileSync(rootManifestPath, "utf-8")) as Record<string, unknown>;
|
|
93
|
+
delete manifest.archive;
|
|
94
|
+
writeFileSync(rootManifestPath, stringify(manifest));
|
|
95
|
+
await createDeterministicTgz(staging, tgz);
|
|
96
|
+
signCommand(tgz, { id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
|
|
97
|
+
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
98
|
+
|
|
99
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
100
|
+
|
|
101
|
+
expect(itemOf(result, "manifest 结构")?.ok).toBe(true);
|
|
102
|
+
rmSync(staging, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("given 预发布版本(文件名含连字符)包体被篡改 When 离线验包", () => {
|
|
107
|
+
it("then 完整性仍能检出(不再因文件名解析错配而静默放过,REG-CS1 边界)", async () => {
|
|
108
|
+
const root = newProject();
|
|
109
|
+
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0-rc.1", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
|
|
110
|
+
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
111
|
+
const tgz = join(root, "cdn", "demo-solution-0.1.0-rc.1.tgz");
|
|
112
|
+
const buf = readFileSync(tgz);
|
|
113
|
+
buf[buf.length - 1] ^= 0xFF; // 篡改末字节
|
|
114
|
+
writeFileSync(tgz, buf);
|
|
115
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
116
|
+
expect(itemOf(result, "完整性")?.ok).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
59
120
|
describe("given 包体被改了一个字节 When 离线验包", () => {
|
|
60
121
|
it("then 完整性与包级签名双双打叉(sha256 不符 + 验签不过)", async () => {
|
|
61
122
|
const root = await seed();
|
|
@@ -84,24 +145,176 @@ describe("given 包级签名被删掉 When 离线验包", () => {
|
|
|
84
145
|
});
|
|
85
146
|
});
|
|
86
147
|
|
|
87
|
-
describe("given
|
|
88
|
-
it("then
|
|
148
|
+
describe("given catalog.yaml 被手改过 When 离线验包", () => {
|
|
149
|
+
it("then 索引签名打叉 index_sig_mismatch(索引签名是完整性层,篡改必须可见)", async () => {
|
|
89
150
|
const root = await seed();
|
|
90
|
-
const
|
|
91
|
-
writeFileSync(
|
|
151
|
+
const catalog = join(root, "cdn", "catalog.yaml");
|
|
152
|
+
writeFileSync(catalog, `${readFileSync(catalog, "utf-8")}\n# tampered\n`);
|
|
92
153
|
|
|
93
154
|
const result = await verifyMarket({ projectRoot: root });
|
|
94
155
|
|
|
95
|
-
expect(
|
|
156
|
+
expect(result.passed).toBe(false);
|
|
157
|
+
expect(sigItems(result).some(c => !c.ok && c.detail.includes("catalog.yaml"))).toBe(true);
|
|
158
|
+
// REG-CS1:篡改与缺失必须可分(前者是安全事件,后者是发布流程漏了)
|
|
159
|
+
expect(sigItems(result).find(c => c.detail.includes("catalog.yaml"))?.code).toBe("index_sig_mismatch");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("given 分片正文被手改过 When 离线验包", () => {
|
|
164
|
+
it("then 该片索引签名打叉 index_sig_mismatch,且完整性同步打叉(sha256 声明已不可信)", async () => {
|
|
165
|
+
const root = await seed();
|
|
166
|
+
const shard = join(root, "cdn", "shards", "1.yaml");
|
|
167
|
+
// 把声明里的 sha256 改掉:片签名失配 + 索引声明的摘要与包体不符,两层都须看见。
|
|
168
|
+
// 篡改值取带字母的十六进制(f…f)——纯数字串会被 YAML 解析成 number,那是另一种形态的坏索引
|
|
169
|
+
const before = readFileSync(shard, "utf-8");
|
|
170
|
+
const text = before.replace(/sha256: [0-9a-f]{64}/, `sha256: ${"f".repeat(64)}`);
|
|
171
|
+
expect(text).not.toEqual(before); // 确认替换命中(夹具确有该字段)
|
|
172
|
+
writeFileSync(shard, text);
|
|
173
|
+
|
|
174
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
175
|
+
|
|
176
|
+
expect(result.passed).toBe(false);
|
|
177
|
+
expect(sigItems(result).find(c => c.detail.includes("shards/1.yaml"))?.code).toBe("index_sig_mismatch");
|
|
178
|
+
expect(itemOf(result, "完整性")?.ok).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("given 索引缺 .sig When 离线验包", () => {
|
|
183
|
+
it("then 索引签名打叉 index_sig_missing 并给两步补签提示(不再「运行期容忍」)", async () => {
|
|
184
|
+
const root = await seed();
|
|
185
|
+
rmSync(join(root, "cdn", "catalog.yaml.sig"));
|
|
186
|
+
rmSync(join(root, "cdn", "shards", "1.yaml.sig"));
|
|
187
|
+
|
|
188
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
189
|
+
|
|
190
|
+
expect(result.passed).toBe(false);
|
|
191
|
+
const bad = sigItems(result).filter(c => !c.ok);
|
|
192
|
+
expect(bad.length).toBeGreaterThanOrEqual(2);
|
|
193
|
+
expect(bad.every(c => c.code === "index_sig_missing")).toBe(true);
|
|
194
|
+
expect(bad[0]?.detail).toContain("ojm market build-index");
|
|
195
|
+
expect(bad[0]?.detail).toContain("ojm publish");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("given 只缺 catalog 的 .sig When 离线验包", () => {
|
|
200
|
+
it("then catalog 项打叉 index_sig_missing、分片项不受牵连(按文件分列的意义)", async () => {
|
|
201
|
+
const root = await seed();
|
|
202
|
+
rmSync(join(root, "cdn", "catalog.yaml.sig"));
|
|
203
|
+
|
|
204
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
205
|
+
|
|
206
|
+
const sigs = sigItems(result);
|
|
207
|
+
expect(sigs.find(c => c.detail.includes("catalog.yaml"))?.code).toBe("index_sig_missing");
|
|
208
|
+
expect(sigs.find(c => c.detail.includes("shards/1.yaml"))?.ok).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("given catalog 正文代次与签名代次不一致 When 离线验包", () => {
|
|
213
|
+
it("then 同代校验打叉报 index_generation_mismatch 并提示「源正在重建索引」(不是「索引被篡改」)", async () => {
|
|
214
|
+
const root = await seed();
|
|
215
|
+
// 改了正文没换签名:正文自称第 7 代、签名还是第 1 代
|
|
216
|
+
const catalog = join(root, "cdn", "catalog.yaml");
|
|
217
|
+
writeFileSync(catalog, readFileSync(catalog, "utf-8").replace(/^seq: \d+$/m, "seq: 7"), "utf-8");
|
|
218
|
+
|
|
219
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
220
|
+
|
|
221
|
+
expect(result.passed).toBe(false);
|
|
222
|
+
const item = itemOf(result, "索引同代");
|
|
223
|
+
expect(item?.ok).toBe(false);
|
|
224
|
+
expect(item?.code).toBe("index_generation_mismatch");
|
|
225
|
+
expect(item?.detail).toContain("源正在重建索引,请稍后重试");
|
|
226
|
+
expect(item?.detail).not.toContain("被篡改");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("given catalog 钉的片代次与分片签名不符 When 离线验包(红队 F2 新形态:新目录钉旧片)", () => {
|
|
231
|
+
it("then 同代校验打叉 index_generation_mismatch(拼不进,按运维事故提示重试)", async () => {
|
|
232
|
+
const root = await seed();
|
|
233
|
+
const cdn = join(root, "cdn");
|
|
234
|
+
// 分片签名重签成第 2 代(签名本身有效),catalog 仍钉第 1 代——「写到一半」的中间态
|
|
235
|
+
const shardText = recomputeIndex(readFileSync(join(cdn, "shards", "1.yaml"), "utf-8"));
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(cdn, "shards", "1.yaml.sig"),
|
|
238
|
+
`${JSON.stringify(signIndex("shard", 2, shardText, testPem(), KEY_ID), null, 2)}\n`,
|
|
239
|
+
"utf-8",
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
243
|
+
|
|
244
|
+
const item = itemOf(result, "索引同代");
|
|
245
|
+
expect(item?.ok).toBe(false);
|
|
246
|
+
expect(item?.code).toBe("index_generation_mismatch");
|
|
247
|
+
expect(item?.detail).toContain("源正在重建索引,请稍后重试");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("given 分片正文没了但 catalog 还钉着它 When 离线验包", () => {
|
|
252
|
+
it("then 同代校验打叉 index_generation_mismatch(代次不可判定即拒,fail-closed)", async () => {
|
|
253
|
+
const root = await seed();
|
|
254
|
+
rmSync(join(root, "cdn", "shards", "1.yaml"));
|
|
255
|
+
rmSync(join(root, "cdn", "shards", "1.yaml.sig"));
|
|
256
|
+
|
|
257
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
258
|
+
|
|
259
|
+
const item = itemOf(result, "索引同代");
|
|
260
|
+
expect(item?.ok).toBe(false);
|
|
261
|
+
expect(item?.code).toBe("index_generation_mismatch");
|
|
262
|
+
expect(item?.detail).toContain("正文缺失");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("given 索引的 .sig 是旧格式(不带 seq)When 离线验包", () => {
|
|
267
|
+
it("then 索引签名打叉并报 index_sig_mismatch(签名未覆盖代次 = 挡不住新旧拼接)", async () => {
|
|
268
|
+
const root = await seed();
|
|
269
|
+
const sigPath = join(root, "cdn", "catalog.yaml.sig");
|
|
270
|
+
const sig = JSON.parse(readFileSync(sigPath, "utf-8")) as Record<string, unknown>;
|
|
271
|
+
delete sig.seq;
|
|
272
|
+
writeFileSync(sigPath, JSON.stringify(sig), "utf-8");
|
|
273
|
+
|
|
274
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
275
|
+
|
|
276
|
+
expect(sigItems(result).find(c => c.detail.includes("catalog.yaml"))?.code).toBe("index_sig_mismatch");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("given 索引已过自声明的失效时刻 When 离线验包", () => {
|
|
281
|
+
it("then 索引时效打叉报 index_expired(REG-CS2:与站点安装同一判定,CLI/API 一致)", async () => {
|
|
282
|
+
const root = await seed();
|
|
283
|
+
const catalogPath = join(root, "cdn", "catalog.yaml");
|
|
284
|
+
const doc = parse(readFileSync(catalogPath, "utf-8")) as Record<string, unknown>;
|
|
285
|
+
doc.issued_at = 1;
|
|
286
|
+
doc.expires_at = 2;
|
|
287
|
+
writeFileSync(catalogPath, stringify(doc), "utf-8");
|
|
288
|
+
|
|
289
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
290
|
+
|
|
291
|
+
expect(itemOf(result, "索引时效")?.ok).toBe(false);
|
|
292
|
+
expect(itemOf(result, "索引时效")?.code).toBe("index_expired");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("given 索引不带时效字段(旧 ojm 产物)When 离线验包", () => {
|
|
297
|
+
it("then 索引时效打叉(不可判定即按过期处理,不默认当新鲜)", async () => {
|
|
298
|
+
const root = await seed();
|
|
299
|
+
const catalogPath = join(root, "cdn", "catalog.yaml");
|
|
300
|
+
const doc = parse(readFileSync(catalogPath, "utf-8")) as Record<string, unknown>;
|
|
301
|
+
delete doc.issued_at;
|
|
302
|
+
delete doc.expires_at;
|
|
303
|
+
writeFileSync(catalogPath, stringify(doc), "utf-8");
|
|
304
|
+
|
|
305
|
+
const result = await verifyMarket({ projectRoot: root });
|
|
306
|
+
|
|
307
|
+
expect(itemOf(result, "索引时效")?.ok).toBe(false);
|
|
308
|
+
expect(itemOf(result, "索引时效")?.code).toBe("index_expired");
|
|
96
309
|
});
|
|
97
310
|
});
|
|
98
311
|
|
|
99
312
|
describe("given 落了 tgz 但没重建索引 When 带 --rebuild-check 验包", () => {
|
|
100
313
|
it("then 报漂移:索引缺该包条目", async () => {
|
|
101
314
|
const root = newProject();
|
|
102
|
-
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
|
|
315
|
+
await publishPackage({ projectRoot: root, fixture: "demo-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
|
|
103
316
|
await buildIndex({ projectRoot: root, id: KEY_ID, privateKeyPem: testPem() });
|
|
104
|
-
await publishPackage({ projectRoot: root, fixture: "other-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem() });
|
|
317
|
+
await publishPackage({ projectRoot: root, fixture: "other-solution@0.1.0", id: KEY_ID, privateKeyPem: testPem(), registryId: REGISTRY_ID });
|
|
105
318
|
|
|
106
319
|
const result = await verifyMarket({ projectRoot: root, rebuildCheck: true });
|
|
107
320
|
|
|
@@ -109,3 +322,14 @@ describe("given 落了 tgz 但没重建索引 When 带 --rebuild-check 验包",
|
|
|
109
322
|
expect(result.drift?.join(" ")).toMatch(/other-solution/);
|
|
110
323
|
});
|
|
111
324
|
});
|
|
325
|
+
|
|
326
|
+
describe("given 索引声明了某个版本但盘上包没了 When 带 --rebuild-check 验包", () => {
|
|
327
|
+
it("then 报反向漂移:索引多条目(只查正向会让「答应有、实际没有」漏过去)", async () => {
|
|
328
|
+
const root = await seed();
|
|
329
|
+
rmSync(join(root, "cdn", "demo-solution-0.1.0.tgz"));
|
|
330
|
+
|
|
331
|
+
const result = await verifyMarket({ projectRoot: root, rebuildCheck: true });
|
|
332
|
+
|
|
333
|
+
expect(result.drift?.join(" ")).toMatch(/索引多条目:demo-solution@0\.1\.0/);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ojm market yank 账本单测(REG-CS4):幂等、审计留痕、不存在版本明确报错、跨重建存活。
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { markYanked, readYankLedger, yankKey } from "../yank-ledger";
|
|
8
|
+
|
|
9
|
+
function newProject(): { root: string, cdn: string } {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "ojm-yank-"));
|
|
11
|
+
mkdirSync(join(root, "api"), { recursive: true });
|
|
12
|
+
const cdn = join(root, "cdn");
|
|
13
|
+
mkdirSync(cdn, { recursive: true });
|
|
14
|
+
writeFileSync(join(root, "api", "config.yaml"), "mounts:\n - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\nserver:\n host: 127.0.0.1\n port: 9778\n", "utf-8");
|
|
15
|
+
writeFileSync(join(cdn, "demo-solution-0.1.0.tgz"), "", "utf-8");
|
|
16
|
+
writeFileSync(join(cdn, "demo-solution-0.2.0.tgz"), "", "utf-8");
|
|
17
|
+
return { root, cdn };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("yank 账本(REG-CS4)", () => {
|
|
21
|
+
it("markYanked 写盘后 readYankLedger 可回读,含 by/reason/at 审计字段", async () => {
|
|
22
|
+
const { root } = newProject();
|
|
23
|
+
markYanked(root, "demo-solution", "0.1.0", { by: "alice", reason: "CVE-2026-1", now: 1_700_000_000 });
|
|
24
|
+
const ledger = readYankLedger(root);
|
|
25
|
+
const e = ledger.get(yankKey("demo-solution", "0.1.0"));
|
|
26
|
+
expect(e).toMatchObject({ name: "demo-solution", version: "0.1.0", by: "alice", reason: "CVE-2026-1", at: 1_700_000_000 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("重复 yank 幂等:不覆盖既有 by/at/reason,created=false", async () => {
|
|
30
|
+
const { root } = newProject();
|
|
31
|
+
markYanked(root, "demo-solution", "0.1.0", { by: "alice", reason: "first", now: 1 });
|
|
32
|
+
const r = markYanked(root, "demo-solution", "0.1.0", { by: "bob", reason: "second", now: 2 });
|
|
33
|
+
expect(r.created).toBe(false);
|
|
34
|
+
const e = readYankLedger(root).get(yankKey("demo-solution", "0.1.0"))!;
|
|
35
|
+
// 首次写入保活,不被第二次覆盖
|
|
36
|
+
expect(e).toMatchObject({ by: "alice", reason: "first", at: 1 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("对 cdn 不存在的版本 yank → 明确报错,账本不动", async () => {
|
|
40
|
+
const { root } = newProject();
|
|
41
|
+
let thrown: unknown;
|
|
42
|
+
try {
|
|
43
|
+
await markYanked(root, "demo-solution", "9.9.9", { by: "x" });
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
thrown = e;
|
|
47
|
+
}
|
|
48
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
49
|
+
expect(String((thrown as Error).message)).toContain("无法下线");
|
|
50
|
+
expect(readYankLedger(root).size).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("账本跨多次索引重建存活(file 不删=yank 不丢)", async () => {
|
|
54
|
+
const { root, cdn } = newProject();
|
|
55
|
+
markYanked(root, "demo-solution", "0.1.0", { by: "alice", now: 5 });
|
|
56
|
+
// 模拟另一版本发布后重建(账本应原样保留)
|
|
57
|
+
writeFileSync(join(cdn, "demo-solution-0.3.0.tgz"), "", "utf-8");
|
|
58
|
+
expect(readYankLedger(root).get(yankKey("demo-solution", "0.1.0"))?.by).toBe("alice");
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/market/bootstrap.ts
CHANGED
|
@@ -140,11 +140,16 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
|
|
|
140
140
|
cfg = cfg.replace("__JWT_SECRET__", randomBytes(32).toString("hex"));
|
|
141
141
|
// 最小 SPA 挂载:真实部署里前端路由(深检会逐条探活)由站点壳承接;没有 web 挂载时
|
|
142
142
|
// 这些路径一律 404,深检会判成 anomaly。这里放一个 index.html 就够(不构建前端)。
|
|
143
|
-
// 挂在 /cdn 之后(前缀特异性匹配,顺序无关,仅为可读)。
|
|
144
143
|
const siteDir = path.join(root, "site");
|
|
145
144
|
mkdirSync(siteDir, { recursive: true });
|
|
146
145
|
writeFileSync(path.join(siteDir, "index.html"), "<!doctype html><html><body>ojm trial site</body></html>\n", "utf-8");
|
|
147
|
-
|
|
146
|
+
// 只改 /cdn 的 web 指向(保留其 headers 缓存头,Q8 yank 120s 设计不能丢),
|
|
147
|
+
// 再把最小 SPA 挂载作为 /cdn 的同级兄弟追加在 headers 块之后(前缀特异性匹配,顺序无关)。
|
|
148
|
+
cfg = cfg.replace("web: ./cdn", `web: ${cdnDir}`);
|
|
149
|
+
cfg = cfg.replace(
|
|
150
|
+
" Cache-Control: 'max-age=60, stale-while-revalidate=60'",
|
|
151
|
+
` Cache-Control: 'max-age=60, stale-while-revalidate=60'\n - prefix: /\n web: ${siteDir}\n spa: true`,
|
|
152
|
+
);
|
|
148
153
|
// 安装目标恒为 release 部署:装好的模块落在 dist/api,dev 形态(--api-path src)发现不了。
|
|
149
154
|
// 故隔离站点以 dist 起站,platform.root 同步改写(oj 校验 root 必须是生效的 api 挂载目录)。
|
|
150
155
|
// root 相对 **config 所在目录** 解析——config 在隔离根,产物在 <root>/dist/api,故不能写死
|
|
@@ -171,7 +176,7 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
|
|
|
171
176
|
|
|
172
177
|
// 6) 隔离 CDN:落 fixture 包 + 重建索引(publish/buildIndex 直落临时根)
|
|
173
178
|
mkdirSync(cdnDir, { recursive: true });
|
|
174
|
-
await publishPackage({ projectRoot: root, fixture: opts.fixture, id: keyId, privateKeyPem: publisher.privateKeyPem });
|
|
179
|
+
await publishPackage({ projectRoot: root, fixture: opts.fixture, id: keyId, privateKeyPem: publisher.privateKeyPem, registryId: "trial.registry" });
|
|
175
180
|
await buildIndex({ projectRoot: root, id: keyId, privateKeyPem: publisher.privateKeyPem });
|
|
176
181
|
|
|
177
182
|
// 7) 后端构建(dist/api + versions.json;安装与 reloading 都依赖它)
|