@oj-module/cli 0.2.30 → 0.2.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
package/src/market/verify.ts
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ojm market verify`(T7
|
|
2
|
+
* `ojm market verify`(T7 + REG-CS5 三级化):离线验包——不起站点、不装、不要凭据、不要 DB。
|
|
3
3
|
*
|
|
4
4
|
* 分四类列结果(包级与索引级**分开**,因为语义不同):
|
|
5
|
-
* - 完整性:tgz 实际 sha256 与索引声明的 archive.sha256
|
|
5
|
+
* - 完整性:tgz 实际 sha256 与索引声明的 archive.sha256 是否一致(三级化后声明住在分片正文里);
|
|
6
6
|
* - 包级签名:`<tgz>.sig` 能否用包内公钥复核规范串 `<name>@<version>:<sha256>`——**这是信任锚**;
|
|
7
7
|
* - 索引签名:索引字节与其 `.sig`——只是完整性层(防目录/索引被改),不是信任锚;
|
|
8
8
|
* - manifest 结构:必填字段、内嵌原生清单 desc 是否非空(oj 装配面必填)、同方案各版本 0001 迁移是否逐字节一致。
|
|
9
9
|
*
|
|
10
10
|
* 复用既有原语,一行密码学都不重写:`lifecycle/sign.ts` 的 canonicalString/verifyCanonical、
|
|
11
|
-
* `publish.ts` 的 indexCanonical、`node:crypto` 的 sha256。
|
|
11
|
+
* `publish.ts` 的 indexCanonical/shardPinsOf、`node:crypto` 的 sha256。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { IndexKind } from "../publish";
|
|
14
15
|
import type { IndexedPackage } from "./index-builder";
|
|
15
16
|
import { createHash } from "node:crypto";
|
|
16
17
|
import fs from "node:fs";
|
|
17
18
|
import path from "node:path";
|
|
18
|
-
import process from "node:process";
|
|
19
19
|
|
|
20
|
+
import process from "node:process";
|
|
20
21
|
import { parse as yamlParse } from "yaml";
|
|
21
|
-
import { canonicalString, verifyCanonical } from "../lifecycle/sign";
|
|
22
|
-
import { indexCanonical } from "../publish";
|
|
22
|
+
import { CANON, canonicalString, verifyCanonical } from "../lifecycle/sign";
|
|
23
|
+
import { indexCanonical, indexSeqOf, indexTimesOf, shardPinsOf } from "../publish";
|
|
23
24
|
import { resolveCdnLayout } from "./cdn-layout";
|
|
24
25
|
import { readTgz } from "./scan";
|
|
25
26
|
|
|
@@ -27,6 +28,12 @@ export interface VerifyCheck {
|
|
|
27
28
|
item: string
|
|
28
29
|
ok: boolean
|
|
29
30
|
detail: string
|
|
31
|
+
/**
|
|
32
|
+
* 失败码(REG-CS1):可区分的三种拒因,供安装侧与报告侧共用同一套判定。
|
|
33
|
+
* `index_sig_missing` 缺 `.sig` / `index_sig_mismatch` 签名不符 /
|
|
34
|
+
* `index_generation_mismatch` 跨文件不同代(运维事故,与安全事件分开报)。
|
|
35
|
+
*/
|
|
36
|
+
code?: string
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export interface VerifyOptions {
|
|
@@ -47,15 +54,23 @@ export interface VerifyResult {
|
|
|
47
54
|
|
|
48
55
|
const BOUNDARY = "完整性 / 来源校验通过——不构成内容安全性背书";
|
|
49
56
|
|
|
57
|
+
/** 三级索引的读面快照:catalog 正文 + 各分片正文(REG-CS5:catalog.yaml + shards/<n>.yaml) */
|
|
58
|
+
interface IndexSnapshot {
|
|
59
|
+
catalogPath: string
|
|
60
|
+
catalogText: string | null
|
|
61
|
+
shardsDir: string
|
|
62
|
+
/** 片号 → 正文(盘上现存、可解析的片;缺文件/不可解析 → null) */
|
|
63
|
+
shardTexts: Map<number, string | null>
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
|
|
51
67
|
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
52
68
|
const layout = resolveCdnLayout(projectRoot);
|
|
53
69
|
const checks: VerifyCheck[] = [];
|
|
54
70
|
const drift: string[] = [];
|
|
55
71
|
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const declared = declaredHashes(manifestsText);
|
|
72
|
+
const index = readIndexSnapshot(layout.dir);
|
|
73
|
+
const declared = declaredHashes(index);
|
|
59
74
|
|
|
60
75
|
const tgzFiles = opts.file !== undefined
|
|
61
76
|
? [opts.file]
|
|
@@ -72,12 +87,19 @@ export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
|
|
|
72
87
|
const name = nameOf(file);
|
|
73
88
|
const version = versionOf(file);
|
|
74
89
|
|
|
90
|
+
// 文件名解析对含连字符的 slug / 预发布版本(x-y-z.tgz)不可靠,故以「内容摘要」兜底匹配:
|
|
91
|
+
// 优先按文件名定位声明;定位不到时改用 sha256 命中索引任一声明条目,
|
|
92
|
+
// 避免预发布包被「未声明」误判而静默放过篡改。
|
|
93
|
+
const declaredShas = new Set(declared.values());
|
|
75
94
|
const want = declared.get(`${name}@${version}`);
|
|
95
|
+
const ok = want === undefined ? declaredShas.has(sha256) : want === sha256;
|
|
76
96
|
checks.push({
|
|
77
97
|
item: "完整性",
|
|
78
|
-
ok
|
|
98
|
+
ok,
|
|
79
99
|
detail: want === undefined
|
|
80
|
-
?
|
|
100
|
+
? (declaredShas.has(sha256)
|
|
101
|
+
? `${file}:文件名未解析到精确版本,但 sha256 与索引某条目一致(${sha256.slice(0, 12)}…)`
|
|
102
|
+
: `${file}:索引未声明该文件名对应版本,且 sha256 与索引任何条目都不一致(疑似篡改或未发布)(${sha256.slice(0, 12)}…)`)
|
|
81
103
|
: `${file}:${want === sha256 ? `sha256 与索引一致(${sha256.slice(0, 12)}…)` : `sha256 不一致(索引 ${want.slice(0, 12)}… vs 实际 ${sha256.slice(0, 12)}…)`}`,
|
|
82
104
|
});
|
|
83
105
|
|
|
@@ -85,12 +107,15 @@ export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
|
|
|
85
107
|
checks.push(...structureCheck(bytes, file));
|
|
86
108
|
}
|
|
87
109
|
|
|
88
|
-
checks.push(indexSigCheck(layout.dir, "
|
|
89
|
-
|
|
110
|
+
checks.push(indexSigCheck(path.join(layout.dir, "catalog.yaml.sig"), "catalog.yaml", "catalog", index.catalogText));
|
|
111
|
+
for (const [no, text] of index.shardTexts)
|
|
112
|
+
checks.push(indexSigCheck(path.join(index.shardsDir, `${no}.yaml.sig`), `shards/${no}.yaml`, "shard", text));
|
|
113
|
+
checks.push(generationCheck(index));
|
|
114
|
+
checks.push(expiryCheck(index.catalogText));
|
|
90
115
|
checks.push(...migrationConsistencyCheck(layout.dir, tgzFiles));
|
|
91
116
|
|
|
92
117
|
if (opts.rebuildCheck === true)
|
|
93
|
-
drift.push(...driftOf(layout.dir));
|
|
118
|
+
drift.push(...driftOf(layout.dir, index));
|
|
94
119
|
|
|
95
120
|
const passed = checks.every(c => c.ok) && drift.length === 0;
|
|
96
121
|
return {
|
|
@@ -103,30 +128,49 @@ export function verifyMarket(opts: VerifyOptions = {}): VerifyResult {
|
|
|
103
128
|
};
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/** 读三级索引快照:catalog + 盘上 `shards/<n>.yaml` 全集(不含 .sig——签名在各 check 里按路径读) */
|
|
132
|
+
function readIndexSnapshot(cdnDir: string): IndexSnapshot {
|
|
133
|
+
const shardsDir = path.join(cdnDir, "shards");
|
|
134
|
+
const shardTexts = new Map<number, string | null>();
|
|
135
|
+
if (fs.existsSync(shardsDir)) {
|
|
136
|
+
for (const f of fs.readdirSync(shardsDir).filter(f => /^\d+\.yaml$/.test(f)).sort()) {
|
|
137
|
+
const no = Number.parseInt(f, 10);
|
|
138
|
+
if (Number.isInteger(no) && no >= 1)
|
|
139
|
+
shardTexts.set(no, readOptional(path.join(shardsDir, f)));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const catalogPath = path.join(cdnDir, "catalog.yaml");
|
|
143
|
+
return { catalogPath, catalogText: readOptional(catalogPath), shardsDir, shardTexts };
|
|
144
|
+
}
|
|
145
|
+
|
|
106
146
|
/**
|
|
107
147
|
* `.sig` 里验签只用到 public_key / value 两字段;缺任一即不可复核。
|
|
108
148
|
* 抽成类型守卫:包级与索引级两处验签共用(DRY),也顺带让调用点的布尔式保持单行
|
|
109
149
|
* (本仓 indent-binary-ops 与 no-mixed-spaces-and-tabs 对续行缩进的要求互相打架)。
|
|
110
150
|
*/
|
|
111
|
-
function hasSigFields(sig: { public_key?: string, value?: string }): sig is { public_key: string, value: string } {
|
|
151
|
+
function hasSigFields(sig: { public_key?: string, value?: string, canon?: string, registry_id?: string }): sig is { public_key: string, value: string, canon?: string, registry_id?: string } {
|
|
112
152
|
return typeof sig.public_key === "string" && typeof sig.value === "string";
|
|
113
153
|
}
|
|
114
154
|
|
|
115
|
-
/** 包级 detached 验签:`<tgz>.sig`
|
|
155
|
+
/** 包级 detached 验签:`<tgz>.sig` 的公钥复核规范串(REG-CS3 v2:`<registry_id>:<slug>@<version>:<sha256>`) */
|
|
116
156
|
function signatureCheck(tgzPath: string, name: string, version: string, sha256: string): VerifyCheck {
|
|
117
157
|
const sigPath = `${tgzPath}.sig`;
|
|
118
158
|
if (!fs.existsSync(sigPath))
|
|
119
|
-
return { item: "包级签名", ok: false, detail: `${path.basename(tgzPath)}:缺 .tgz.sig(sig_missing)——用 ojm market publish 重发,或向发布方索取签名包` };
|
|
159
|
+
return { item: "包级签名", ok: false, code: "sig_missing", detail: `${path.basename(tgzPath)}:缺 .tgz.sig(sig_missing)——用 ojm market publish 重发,或向发布方索取签名包` };
|
|
120
160
|
try {
|
|
121
|
-
const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string };
|
|
122
|
-
|
|
161
|
+
const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string, canon?: string, registry_id?: string };
|
|
162
|
+
// REG-CS3:规范串版本标记必须 v2(旧 ojm 签的 v1 .sig 一律拒,不兼容,逼重签)
|
|
163
|
+
if (sig.canon !== CANON)
|
|
164
|
+
return { item: "包级签名", ok: false, code: "canon_stale", detail: `${path.basename(sigPath)}:规范串版本过旧(${sig.canon ?? "缺失"},本机要求 ${CANON})——请用当前 ojm 重签` };
|
|
165
|
+
const canonical = canonicalString(sig.registry_id ?? "", name, version, sha256);
|
|
123
166
|
const ok = hasSigFields(sig) && verifyCanonical(sig.public_key, canonical, sig.value);
|
|
124
167
|
return {
|
|
125
168
|
item: "包级签名",
|
|
126
169
|
ok,
|
|
170
|
+
code: ok ? undefined : "signature_mismatch",
|
|
127
171
|
detail: ok
|
|
128
|
-
? `${path.basename(tgzPath)}:Ed25519
|
|
129
|
-
: `${path.basename(tgzPath)}
|
|
172
|
+
? `${path.basename(tgzPath)}:Ed25519 验签通过(registry_id ${sig.registry_id ?? "∅"},规范串 ${sig.registry_id ?? "∅"}:${name}@${version}:${sha256.slice(0, 12)}…)`
|
|
173
|
+
: `${path.basename(tgzPath)}:验签失败(signature_mismatch)——包体被改或签名不匹配,请用当前 ojm 重签`,
|
|
130
174
|
};
|
|
131
175
|
}
|
|
132
176
|
catch (error) {
|
|
@@ -134,23 +178,123 @@ function signatureCheck(tgzPath: string, name: string, version: string, sha256:
|
|
|
134
178
|
}
|
|
135
179
|
}
|
|
136
180
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
181
|
+
/**
|
|
182
|
+
* 索引级验签(REG-CS1①:fail-closed,缺签名即拒,不再「运行期容忍」)。
|
|
183
|
+
*
|
|
184
|
+
* 为什么不能容忍:容忍等于把「索引完整性」交给发布方的自觉——谁改了 catalog.yaml 都能装。
|
|
185
|
+
* 规范串带 seq(`index:<kind>:<seq>:<sha256>`),故 `.sig` 必须带 seq 才可能验过。
|
|
186
|
+
* 分片(kind=shard)的 seq 不取自正文(纯内容无元字段),由 catalog 的 `shards` 钉值在
|
|
187
|
+
* 同代校验里核对;此处只验「签名本身与字节匹配且覆盖代次」。
|
|
188
|
+
*/
|
|
189
|
+
function indexSigCheck(sigPath: string, label: string, kind: IndexKind, text: string | null): VerifyCheck {
|
|
139
190
|
if (text === null)
|
|
140
|
-
return { item: "索引签名", ok: true, detail: `${
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
191
|
+
return { item: "索引签名", ok: true, detail: `${label} 不存在,跳过` };
|
|
192
|
+
if (!fs.existsSync(sigPath)) {
|
|
193
|
+
return {
|
|
194
|
+
item: "索引签名",
|
|
195
|
+
ok: false,
|
|
196
|
+
code: "index_sig_missing",
|
|
197
|
+
detail: `${label}.sig 缺失(index_sig_missing)——完整性无保障,安装侧会拒装。下一步:① ojm market build-index --id <publisher.id> 重建并签名;② 或对既有索引补签 ojm publish <目录> --id <publisher.id>`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
144
200
|
try {
|
|
145
|
-
const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string };
|
|
146
|
-
const
|
|
147
|
-
|
|
201
|
+
const sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { public_key?: string, value?: string, seq?: unknown };
|
|
202
|
+
const seq = sigSeqOf(sig);
|
|
203
|
+
if (seq === null)
|
|
204
|
+
return { item: "索引签名", ok: false, code: "index_sig_mismatch", detail: `${label}.sig 缺 seq 字段——签名未覆盖代次,请用当前 ojm 重签` };
|
|
205
|
+
const ok = hasSigFields(sig) && verifyCanonical(sig.public_key, indexCanonical(kind, seq, text), sig.value);
|
|
206
|
+
return {
|
|
207
|
+
item: "索引签名",
|
|
208
|
+
ok,
|
|
209
|
+
code: ok ? undefined : "index_sig_mismatch",
|
|
210
|
+
detail: ok
|
|
211
|
+
? `${label} 验签通过(第 ${seq} 代)`
|
|
212
|
+
: `${label} 验签失败(index_sig_mismatch)——索引被改或签名不符`,
|
|
213
|
+
};
|
|
148
214
|
}
|
|
149
215
|
catch (error) {
|
|
150
|
-
return { item: "索引签名", ok: false, detail: `${
|
|
216
|
+
return { item: "索引签名", ok: false, code: "index_sig_mismatch", detail: `${label}.sig 不可解析:${(error as Error).message}` };
|
|
151
217
|
}
|
|
152
218
|
}
|
|
153
219
|
|
|
220
|
+
/** `.sig` 的 seq 字段(非正整数即 null——代次必须是可比较的整数) */
|
|
221
|
+
function sigSeqOf(sig: { seq?: unknown }): number | null {
|
|
222
|
+
const seq = sig.seq;
|
|
223
|
+
return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* REG-CS1② 同代校验(三级化):代次真源 = **catalog**(顶层 `seq` + `shards` 钉值表)。
|
|
228
|
+
*
|
|
229
|
+
* 每片被 catalog 钉住一个代次,其 `.sig.seq` 必须等于钉值——不允许把「新总目录」和
|
|
230
|
+
* 「对不上代的分册」拼起来用(红队 F2:写索引写到一半进程被杀就会留下这种中间态)。
|
|
231
|
+
* 未变的片沿用旧代次是**设计允许**的(内容逐字节未动,拼起来永远同代同内容),
|
|
232
|
+
* 判定口径因此是「签名代次 == catalog 钉值」而非「== catalog 代次」。
|
|
233
|
+
* 这是**运维事故**不是安全事件,所以错误码与「签名不符」分开,提示也指向「稍后重试」。
|
|
234
|
+
*/
|
|
235
|
+
export function generationCheck(index: IndexSnapshot): VerifyCheck {
|
|
236
|
+
const present = index.catalogText !== null;
|
|
237
|
+
if (!present && index.shardTexts.size === 0)
|
|
238
|
+
return { item: "索引同代", ok: true, detail: "索引文件缺失,跳过同代校验" };
|
|
239
|
+
if (!present) {
|
|
240
|
+
return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `catalog.yaml 缺失但 shards/ 里有 ${index.shardTexts.size} 片——源正在重建索引或索引被手改,请稍后重试(index_generation_mismatch)` };
|
|
241
|
+
}
|
|
242
|
+
const catalogSeq = indexSeqOf(index.catalogText!);
|
|
243
|
+
if (catalogSeq === null)
|
|
244
|
+
return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: "catalog.yaml 缺顶层 seq 字段——用 ojm market build-index 重建索引" };
|
|
245
|
+
// catalog 正文代次与签名代次必须一致(防「改了正文没换签名」的半个写入)
|
|
246
|
+
const catalogSigSeq = sigSeqOf(readSigJson(`${index.catalogPath}.sig`));
|
|
247
|
+
if (catalogSigSeq !== catalogSeq)
|
|
248
|
+
return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `catalog.yaml 正文是第 ${catalogSeq} 代、签名是第 ${String(catalogSigSeq ?? "无")} 代——源正在重建索引,请稍后重试(index_generation_mismatch)` };
|
|
249
|
+
const pins = shardPinsOf(index.catalogText!);
|
|
250
|
+
for (const [no, pinSeq] of pins) {
|
|
251
|
+
// 钉值分册的正文必须存在:catalog 引用了某分册但盘上缺失 → 安装会 404,须判失败而非放过。
|
|
252
|
+
if (!fs.existsSync(path.join(index.shardsDir, `${no}.yaml`))) {
|
|
253
|
+
return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `shards/${no}.yaml 正文缺失(catalog 钉第 ${pinSeq} 代)——分册丢失或源正在重建索引,请稍后重试(index_generation_mismatch)` };
|
|
254
|
+
}
|
|
255
|
+
const sig = readSigJson(path.join(index.shardsDir, `${no}.yaml.sig`));
|
|
256
|
+
const sigSeq = sigSeqOf(sig);
|
|
257
|
+
if (sigSeq === null)
|
|
258
|
+
return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `shards/${no}.yaml 签名代次不可判定(catalog 钉第 ${pinSeq} 代)——请用当前 ojm 重签` };
|
|
259
|
+
if (sigSeq !== pinSeq)
|
|
260
|
+
return { item: "索引同代", ok: false, code: "index_generation_mismatch", detail: `shards/${no}.yaml 签名是第 ${sigSeq} 代,catalog 钉的是第 ${pinSeq} 代——源正在重建索引,请稍后重试(index_generation_mismatch)` };
|
|
261
|
+
}
|
|
262
|
+
return { item: "索引同代", ok: true, detail: `catalog 第 ${catalogSeq} 代,${pins.size} 片代次与钉值一致` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 读 .sig JSON(不可解析 → 空对象,由调用方按 fail-closed 处理) */
|
|
266
|
+
function readSigJson(sigPath: string): { seq?: unknown } {
|
|
267
|
+
try {
|
|
268
|
+
return JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { seq?: unknown };
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* REG-CS2 索引时效:catalog 自带 `issued_at` / `expires_at`,过了失效时刻的索引不能再用来装。
|
|
277
|
+
* 与站点 `market/api.ts guardCatalogFreshness` 同一判定(CLI/API 一致,D1)。
|
|
278
|
+
*/
|
|
279
|
+
export function expiryCheck(text: string | null): VerifyCheck {
|
|
280
|
+
if (text === null)
|
|
281
|
+
return { item: "索引时效", ok: true, detail: "索引文件缺失,跳过时效校验" };
|
|
282
|
+
const times = indexTimesOf(text);
|
|
283
|
+
if (times === null)
|
|
284
|
+
return { item: "索引时效", ok: false, code: "index_expired", detail: "catalog.yaml 缺 issued_at / expires_at——索引未带时效,用 ojm market build-index 重建" };
|
|
285
|
+
const now = Math.floor(Date.now() / 1000);
|
|
286
|
+
const ok = now <= times.expires_at;
|
|
287
|
+
const at = new Date(times.expires_at * 1000).toISOString();
|
|
288
|
+
return {
|
|
289
|
+
item: "索引时效",
|
|
290
|
+
ok,
|
|
291
|
+
code: ok ? undefined : "index_expired",
|
|
292
|
+
detail: ok
|
|
293
|
+
? `索引有效至 ${at}`
|
|
294
|
+
: `索引已于 ${at} 失效(index_expired)——源未在其自声明的有效期内重建,请重建后再发布`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
154
298
|
/** manifest 结构:必填字段 + 内嵌原生清单 desc 非空(等值要求已移除,见 §9.3) */
|
|
155
299
|
function structureCheck(bytes: Uint8Array, file: string): VerifyCheck[] {
|
|
156
300
|
let entries: ReturnType<typeof readTgz>;
|
|
@@ -228,8 +372,8 @@ function migrationConsistencyCheck(cdnDir: string, files: string[]): VerifyCheck
|
|
|
228
372
|
return out;
|
|
229
373
|
}
|
|
230
374
|
|
|
231
|
-
/**
|
|
232
|
-
function driftOf(cdnDir: string): string[] {
|
|
375
|
+
/** 扫描重建出的索引与盘上索引双向比对:检出「落了包没重建 / 索引被手改 / 索引多条目」 */
|
|
376
|
+
function driftOf(cdnDir: string, index: IndexSnapshot): string[] {
|
|
233
377
|
const out: string[] = [];
|
|
234
378
|
const files = fs.readdirSync(cdnDir).filter(f => f.endsWith(".tgz")).sort();
|
|
235
379
|
const scanned: IndexedPackage[] = [];
|
|
@@ -246,36 +390,55 @@ function driftOf(cdnDir: string): string[] {
|
|
|
246
390
|
continue;
|
|
247
391
|
scanned.push({ name, version, file, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.byteLength, manifest });
|
|
248
392
|
}
|
|
249
|
-
|
|
250
|
-
if (onDisk === null) {
|
|
393
|
+
if (index.catalogText === null) {
|
|
251
394
|
if (scanned.length > 0)
|
|
252
|
-
out.push(`盘上没有
|
|
395
|
+
out.push(`盘上没有 catalog.yaml,但目录里有 ${scanned.length} 个包——索引从未重建`);
|
|
253
396
|
return out;
|
|
254
397
|
}
|
|
255
|
-
|
|
398
|
+
// 三级化后版本明细住在分片里:全集 = 各片 solutions[].versions
|
|
256
399
|
const inIndex = new Set<string>();
|
|
257
|
-
for (const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
400
|
+
for (const text of index.shardTexts.values()) {
|
|
401
|
+
if (text === null)
|
|
402
|
+
continue;
|
|
403
|
+
const doc = (yamlParse(text) ?? { solutions: [] }) as { solutions?: Array<Record<string, unknown>> };
|
|
404
|
+
for (const solution of doc.solutions ?? []) {
|
|
405
|
+
const name = String(solution.name ?? "");
|
|
406
|
+
for (const v of (solution.versions ?? []) as Array<Record<string, unknown>>)
|
|
407
|
+
inIndex.add(`${name}@${String(v.version ?? "")}`);
|
|
408
|
+
}
|
|
261
409
|
}
|
|
410
|
+
const onDisk = new Set(scanned.map(p => `${p.name}@${p.version}`));
|
|
262
411
|
for (const pkg of scanned) {
|
|
263
412
|
if (!inIndex.has(`${pkg.name}@${pkg.version}`))
|
|
264
413
|
out.push(`索引缺条目:${pkg.name}@${pkg.version}(${pkg.file})——落了包但没跑 ojm market build-index`);
|
|
265
414
|
}
|
|
415
|
+
// 反向漂移:索引钉了某个版本、盘上却没有对应 tgz(包被删/被挤走没重建)——
|
|
416
|
+
// 只查正向会让「索引答应有、实际没有」悄悄漏过去,安装侧拉到 404 才知道
|
|
417
|
+
for (const key of inIndex) {
|
|
418
|
+
if (onDisk.has(key))
|
|
419
|
+
continue;
|
|
420
|
+
const [name, version] = key.split("@") as [string, string];
|
|
421
|
+
out.push(`索引多条目:${name}@${version}——索引声明了该版本但 cdn/ 里没有 ${name}-${version}.tgz,跑 ojm market build-index 重建`);
|
|
422
|
+
}
|
|
266
423
|
return out;
|
|
267
424
|
}
|
|
268
425
|
|
|
269
|
-
|
|
426
|
+
/** 索引声明的 `name@version → sha256`(三级化后声明住在分片正文的 archive.sha256) */
|
|
427
|
+
function declaredHashes(index: IndexSnapshot): Map<string, string> {
|
|
270
428
|
const out = new Map<string, string>();
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
429
|
+
for (const text of index.shardTexts.values()) {
|
|
430
|
+
if (text === null)
|
|
431
|
+
continue;
|
|
432
|
+
const doc = (yamlParse(text) ?? { solutions: [] }) as { solutions?: Array<Record<string, unknown>> };
|
|
433
|
+
for (const solution of doc.solutions ?? []) {
|
|
434
|
+
const name = String(solution.name ?? "");
|
|
435
|
+
for (const v of (solution.versions ?? []) as Array<Record<string, unknown>>) {
|
|
436
|
+
const archive = v.archive as Record<string, unknown> | undefined;
|
|
437
|
+
const sha = archive?.sha256;
|
|
438
|
+
if (typeof sha === "string")
|
|
439
|
+
out.set(`${name}@${String(v.version ?? "")}`, sha);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
279
442
|
}
|
|
280
443
|
return out;
|
|
281
444
|
}
|
|
@@ -20,6 +20,7 @@ import fs from "node:fs";
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import process from "node:process";
|
|
22
22
|
import { resolveLayout } from "../layout";
|
|
23
|
+
import { writeIndexFile } from "../publish";
|
|
23
24
|
import { signCommand } from "../sign";
|
|
24
25
|
import { callApi, login, pollTask, unwrapEnvelope } from "../site";
|
|
25
26
|
import { bootstrapIsolatedSite, derivePublisherKey } from "./bootstrap";
|
|
@@ -274,10 +275,10 @@ const OPS: Op[] = [
|
|
|
274
275
|
title: "负向:索引篡改(验签拒载)",
|
|
275
276
|
run: async (ctx) => {
|
|
276
277
|
await publishVersion(ctx, NEG_VERSION);
|
|
277
|
-
const
|
|
278
|
-
const orig = fs.readFileSync(
|
|
278
|
+
const catalog = path.join(ctx.site.cdnDir, "catalog.yaml");
|
|
279
|
+
const orig = fs.readFileSync(catalog);
|
|
279
280
|
try {
|
|
280
|
-
fs.appendFileSync(
|
|
281
|
+
fs.appendFileSync(catalog, "# tampered\n"); // 索引字节变、签名失配
|
|
281
282
|
let msg = "";
|
|
282
283
|
try {
|
|
283
284
|
await installOnce(ctx, NEG_VERSION);
|
|
@@ -289,7 +290,7 @@ const OPS: Op[] = [
|
|
|
289
290
|
return { ok, detail: ok ? "安装 POST 即 502 拒载(G4 fail-closed),任务未创建" : `未按预期拒装:${msg === "" ? "安装竟成功" : msg}` };
|
|
290
291
|
}
|
|
291
292
|
finally {
|
|
292
|
-
|
|
293
|
+
writeIndexFile(catalog, orig.toString("utf-8")); // 回抄原始字节(也走唯一写入口,不留下直写索引的路径)
|
|
293
294
|
await removeVersion(ctx, NEG_VERSION);
|
|
294
295
|
}
|
|
295
296
|
},
|
|
@@ -374,7 +375,7 @@ export async function walkthrough(opts: WalkthroughOptions = {}): Promise<Walkth
|
|
|
374
375
|
const site = await bootstrapIsolatedSite({ projectRoot: opts.projectRoot, fixture: opts.fixture, keep: opts.keep, onLog: log });
|
|
375
376
|
try {
|
|
376
377
|
const newPass = "trial-pass-1";
|
|
377
|
-
const setup = await setupMarketUrl({ projectRoot: site.root,
|
|
378
|
+
const setup = await setupMarketUrl({ projectRoot: site.root, siteUrl: site.baseUrl, user: site.admin.user, pass: site.admin.pass, newPass });
|
|
378
379
|
const token = await login(fetch, site.baseUrl, site.admin.user, loginPasswordAfterSetup(setup, newPass, site.admin.pass));
|
|
379
380
|
const ctx: Ctx = { site, base: site.baseUrl, token, name: spec.name, version: spec.version, log };
|
|
380
381
|
|
|
@@ -467,7 +468,7 @@ async function indexCard(ctx: Ctx): Promise<Record<string, unknown> | undefined>
|
|
|
467
468
|
|
|
468
469
|
/** 发布某版本 fixture 进隔离 CDN 并重铺索引(upgrade/rollback/neg-* 的共用前置) */
|
|
469
470
|
async function publishVersion(ctx: Ctx, version: string): Promise<void> {
|
|
470
|
-
await publishPackage({ projectRoot: ctx.site.root, fixture: `${ctx.name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
|
|
471
|
+
await publishPackage({ projectRoot: ctx.site.root, fixture: `${ctx.name}@${version}`, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem, registryId: "trial.registry" });
|
|
471
472
|
await buildIndex({ projectRoot: ctx.site.root, id: ctx.site.publisher.keyId, privateKeyPem: ctx.site.publisher.privateKeyPem });
|
|
472
473
|
}
|
|
473
474
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yank 账本(REG-CS4):把「某版本已下线」这个事实从索引正文里抽出来,单独持久化。
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立成账本:索引是**派生重建产物**(`build-index` 每次全量重扫 `cdn/*.tgz`),
|
|
5
|
+
* tgz 文件本身不删除(yank ≠ delete)——所以「谁被下线」不能靠「文件不在」推断,必须有
|
|
6
|
+
* 一份能跨重建存活的真源。账本就是这份真源:`cdn/yanked.json`(`name@version -> 条目`)。
|
|
7
|
+
*
|
|
8
|
+
* 重建索引时,`build-index` 读账本把对应版本标 `yanked: true` + `yanked_at`,并让 `latest`
|
|
9
|
+
* 跳过已下线版本。`ojm market yank <name>@<version>` 写账本后顺手重建索引。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { resolveCdnLayout } from "./cdn-layout";
|
|
16
|
+
|
|
17
|
+
/** 单条 yank 记录(审计留痕:谁、何时、为何) */
|
|
18
|
+
export interface YankEntry {
|
|
19
|
+
/** 方案名 */
|
|
20
|
+
name: string
|
|
21
|
+
/** 版本 */
|
|
22
|
+
version: string
|
|
23
|
+
/** 下线时刻(epoch 秒) */
|
|
24
|
+
at: number
|
|
25
|
+
/** 操作人(CLI 本地形态来自 --by;registry 上线后由会话身份注入) */
|
|
26
|
+
by: string
|
|
27
|
+
/** 下线原因(可选,审计用) */
|
|
28
|
+
reason?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 账本 key:`<name>@<version>`,与 install 侧 resolveCdn 的命中口径一致 */
|
|
32
|
+
export function yankKey(name: string, version: string): string {
|
|
33
|
+
return `${name}@${version}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 账本磁盘路径:`cdn/yanked.json` */
|
|
37
|
+
export function yankLedgerPath(projectRoot: string): string {
|
|
38
|
+
return path.join(resolveCdnLayout(projectRoot).dir, "yanked.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 读账本。文件不存在 → 空表(视为「无下线记录」);非法 JSON → 抛错(fail-closed,不静默当空)。
|
|
43
|
+
* 返回以 `yankKey` 为键的条目表,供 build-index 快速查某版本是否被下线。
|
|
44
|
+
*/
|
|
45
|
+
export function readYankLedger(projectRoot: string): Map<string, YankEntry> {
|
|
46
|
+
const file = yankLedgerPath(projectRoot);
|
|
47
|
+
if (!fs.existsSync(file))
|
|
48
|
+
return new Map();
|
|
49
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as unknown;
|
|
50
|
+
if (!isObj(parsed))
|
|
51
|
+
throw new Error(`[ojm] yank 账本 ${file} 不是合法 JSON 对象(应为 name@version → 条目 的映射)`);
|
|
52
|
+
const out = new Map<string, YankEntry>();
|
|
53
|
+
for (const [key, raw] of Object.entries(parsed)) {
|
|
54
|
+
if (!isObj(raw))
|
|
55
|
+
continue;
|
|
56
|
+
const { name, version, at, by, reason } = raw as Record<string, unknown>;
|
|
57
|
+
if (typeof name !== "string" || typeof version !== "string" || typeof at !== "number" || typeof by !== "string")
|
|
58
|
+
continue;
|
|
59
|
+
out.set(key, { name, version, at, by, ...(typeof reason === "string" ? { reason } : {}) });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 标记某版本已下线(写账本)。幂等:重复 yank 同一版本不重复写、返回 created=false。
|
|
66
|
+
* 目标版本在 `cdn/*.tgz` 中不存在 → 抛明确错误(REG-CS4 验收:对不存在的版本报明确错误)。
|
|
67
|
+
*/
|
|
68
|
+
export function markYanked(
|
|
69
|
+
projectRoot: string,
|
|
70
|
+
name: string,
|
|
71
|
+
version: string,
|
|
72
|
+
opts: { by: string, reason?: string, now?: number },
|
|
73
|
+
): { created: boolean } {
|
|
74
|
+
const file = yankLedgerPath(projectRoot);
|
|
75
|
+
const dir = path.dirname(file);
|
|
76
|
+
// 目标版本必须真实存在于 cdn(文件名严格 <name>-<version>.tgz)——yank 的是「已发布版本」
|
|
77
|
+
if (!fs.existsSync(path.join(dir, `${name}-${version}.tgz`)))
|
|
78
|
+
throw new Error(`[ojm] 无法下线 ${name}@${version}:cdn 目录下不存在 ${name}-${version}.tgz(只可下线已发布的版本)`);
|
|
79
|
+
const ledger = readYankLedger(projectRoot);
|
|
80
|
+
const key = yankKey(name, version);
|
|
81
|
+
if (ledger.has(key))
|
|
82
|
+
return { created: false };
|
|
83
|
+
ledger.set(key, { name, version, at: opts.now ?? Math.floor(Date.now() / 1000), by: opts.by, ...(opts.reason !== undefined ? { reason: opts.reason } : {}) });
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(file, `${JSON.stringify(Object.fromEntries(ledger), null, 2)}\n`);
|
|
86
|
+
return { created: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
90
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
91
|
+
}
|