@oj-module/cli 0.2.29 → 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/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +3 -3
- 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/src/system/web/entry.ts +5 -2
- 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/publish.ts
CHANGED
|
@@ -5,7 +5,8 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
8
|
-
|
|
8
|
+
import { UsageError } from "./exit-codes";
|
|
9
|
+
// ojm publish(P3-5/6/7,作者工具链):对发布目录的 REG-CS5 三级索引(catalog.yaml + shards/<n>.yaml)
|
|
9
10
|
// 重算(P3-6:parse→确定性 stringify,消弭格式漂移、使 sha256 稳定可签)→ 签名(P3-7:detached
|
|
10
11
|
// 侧车 .sig,与 archive .sig 同构)→ 写回目录(本地文件目标;CDN 推送走 resolveCdn 同形)。
|
|
11
12
|
// 复用 sign.ts 签名原语 + 发布方 key(~/.ojm/keys,与 ojm sign 同身份体系)。
|
|
@@ -18,34 +19,147 @@ export interface PublishOptions {
|
|
|
18
19
|
id?: string
|
|
19
20
|
/** 私钥 PEM 文件(缺省 ~/.ojm/keys/<id>/private.pem) */
|
|
20
21
|
key?: string
|
|
21
|
-
/** 写完离线自检(verifyCanonical
|
|
22
|
+
/** 写完离线自检(verifyCanonical 复核 catalog 与各分片签名) */
|
|
22
23
|
check?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* `--dry-run`(REG-R12):只算不写——重算后的正文、代次、目标路径都算出来,
|
|
26
|
+
* 但一个字节都不落盘(CI 用它先看「会改哪些文件」再决定是否真发)。
|
|
27
|
+
*/
|
|
28
|
+
dryRun?: boolean
|
|
29
|
+
/** 输出 sink(`--json` 下由 CLI 改指向 stderr;缺省 console.log) */
|
|
30
|
+
log?: (line: string) => void
|
|
23
31
|
/** 测试注入;缺省 process.cwd() */
|
|
24
32
|
cwd?: string
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export interface PublishResult {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
catalogPath: string
|
|
37
|
+
catalogSigPath: string
|
|
38
|
+
/** 本次补签覆盖的分片正文(按片号升序) */
|
|
39
|
+
shardPaths: string[]
|
|
40
|
+
/** 本次补签覆盖的分片签名(与 shardPaths 同序) */
|
|
41
|
+
shardSigPaths: string[]
|
|
32
42
|
keyId: string
|
|
33
43
|
publicKeyHex: string
|
|
34
44
|
fingerprint: string
|
|
45
|
+
/** 索引代次(catalog 顶层 seq) */
|
|
46
|
+
seq: number
|
|
47
|
+
/** `--dry-run`:true = 本次只算不写(writes 全是「本应写入」的路径) */
|
|
48
|
+
dryRun: boolean
|
|
49
|
+
/** 本次涉及的文件(落盘顺序:先分片 → 再 catalog;dry-run 下同样返回,便于比对) */
|
|
50
|
+
writes: string[]
|
|
35
51
|
/** --check 的自检结果(未传 --check = undefined) */
|
|
36
52
|
checkPassed?: boolean
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
/**
|
|
40
|
-
export
|
|
55
|
+
/** 索引文件的种类(REG-CS1 同代校验:签名覆盖 (kind, seq),换 kind 或换代签名即失效) */
|
|
56
|
+
export type IndexKind = "catalog" | "shard";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 索引有效期(REG-CS2,秒)。全仓只此一处——生成器盖章与读方判过期共用同一个数。
|
|
60
|
+
* 7 天的理由:索引是内容分发不是安全裁决,窗口要够「源方休假回来重建」;真出事靠
|
|
61
|
+
* `seq` 回退即拒与吊销通道兜底,不靠把 TTL 压到小时级。
|
|
62
|
+
*/
|
|
63
|
+
export const INDEX_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
64
|
+
|
|
65
|
+
/** 索引元字段(代次 + 时效),盖进 catalog.yaml 顶层并由签名锁定 */
|
|
66
|
+
export interface IndexMeta {
|
|
67
|
+
seq: number
|
|
68
|
+
/** 签发时刻(epoch 秒) */
|
|
69
|
+
issued_at: number
|
|
70
|
+
/** 失效时刻(epoch 秒;读方 now > 此值即拒,REG-CS2) */
|
|
71
|
+
expires_at: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 索引规范串(REG-CS1/CS2):`index:<kind>:<seq>:<sha256hex>`,与验签侧(market/api.ts)逐字一致。
|
|
76
|
+
*
|
|
77
|
+
* **seq 为什么进串**:只签文本哈希时,「今年的总目录」与「去年的分册」各自都能验过,
|
|
78
|
+
* 读者能把两代拼起来用(红队 F2)。进串之后签名即锁定「第几代」,不同代拼不起来。
|
|
79
|
+
*/
|
|
80
|
+
export function indexCanonical(kind: IndexKind, seq: number, text: string): string {
|
|
81
|
+
const sha256 = createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
|
82
|
+
return `index:${kind}:${seq}:${sha256}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 吊销清单规范串(REG-R10):`revocations:<seq>:<sha256hex(正文)>`——与 oj-registry 的
|
|
87
|
+
* `revocationCanonical` 逐字一致。offline import 复用 `verifyCanonical` 验签时,canon 必须同源。
|
|
88
|
+
*/
|
|
89
|
+
export function revocationCanonical(seq: number, text: string): string {
|
|
41
90
|
const sha256 = createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
|
42
|
-
return `
|
|
91
|
+
return `revocations:${seq}:${sha256}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 对单个索引文本签名,产出 detached .sig 六字段(与 archive .sig 同构,契约 §2.11 / C2-35 + seq) */
|
|
95
|
+
export function signIndex(kind: IndexKind, seq: number, text: string, privateKeyPem: string, keyId: string): { alg: string, key_id: string, public_key: string, value: string, canon: string, seq: number } {
|
|
96
|
+
const value = signCanonical(privateKeyPem, indexCanonical(kind, seq, text));
|
|
97
|
+
return { alg: SIG_ALG, key_id: keyId, public_key: exportRawPublicKeyHex(privateKeyPem), value, canon: CANON, seq };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 索引代次(REG-CS2):顶层 `seq` 字段;纯内容形态的索引(如分片正文)没有顶层键 → null,代次只由 .sig 携带 */
|
|
101
|
+
export function indexSeqOf(text: string): number | null {
|
|
102
|
+
let doc: unknown;
|
|
103
|
+
try {
|
|
104
|
+
doc = yamlParse(text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
110
|
+
return null;
|
|
111
|
+
const seq = (doc as Record<string, unknown>).seq;
|
|
112
|
+
return typeof seq === "number" && Number.isInteger(seq) && seq >= 1 ? seq : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 一次「补签」的代次(REG-CS5 三级化后只剩 catalog 一个代次真源):
|
|
117
|
+
* catalog 无顶层 seq → 首代 1;各分片的代次不归这里裁决——分片代次由 catalog 的
|
|
118
|
+
* `shards` 钉值承载(`shards: {<片号>: <代次>}`),逐片在 publishCommand 里与盘上 .sig 交叉核对。
|
|
119
|
+
*/
|
|
120
|
+
export function resolveIndexSeq(catalogText: string): number {
|
|
121
|
+
const seq = indexSeqOf(catalogText);
|
|
122
|
+
if (seq === null)
|
|
123
|
+
return 1;
|
|
124
|
+
return seq;
|
|
43
125
|
}
|
|
44
126
|
|
|
45
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
const
|
|
48
|
-
|
|
127
|
+
/** 把代次与时效盖进索引文本(parse → 置 seq/issued_at/expires_at → 确定性 stringify),使哈希覆盖代次 */
|
|
128
|
+
export function stampIndexMeta(text: string, meta: IndexMeta): string {
|
|
129
|
+
const doc = yamlParse(text);
|
|
130
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
131
|
+
throw new Error("[ojm] 索引不是键值文档,无法盖章代次(纯内容形态的索引代次由 .sig 携带,不盖正文)");
|
|
132
|
+
const out = doc as Record<string, unknown>;
|
|
133
|
+
out.seq = meta.seq;
|
|
134
|
+
out.issued_at = meta.issued_at;
|
|
135
|
+
out.expires_at = meta.expires_at;
|
|
136
|
+
return yamlStringify(sortKeysDeep(out), { lineWidth: 0 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 按当前时刻算一份时效(`issued_at` = now,`expires_at` = now + TTL)。
|
|
141
|
+
* `now` 可注入,使重建结果在用例里可断言。
|
|
142
|
+
*/
|
|
143
|
+
export function indexMetaOf(seq: number, now: number): IndexMeta {
|
|
144
|
+
return { seq, issued_at: now, expires_at: now + INDEX_TTL_SECONDS };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 索引时效(读方据此判过期;缺任一字段或不是数 → null,按 REG-CS2 fail-closed 拒) */
|
|
148
|
+
export function indexTimesOf(text: string): { issued_at: number, expires_at: number } | null {
|
|
149
|
+
let doc: unknown;
|
|
150
|
+
try {
|
|
151
|
+
doc = yamlParse(text);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
157
|
+
return null;
|
|
158
|
+
const issuedAt = (doc as Record<string, unknown>).issued_at;
|
|
159
|
+
const expiresAt = (doc as Record<string, unknown>).expires_at;
|
|
160
|
+
if (typeof issuedAt !== "number" || typeof expiresAt !== "number")
|
|
161
|
+
return null;
|
|
162
|
+
return { issued_at: issuedAt, expires_at: expiresAt };
|
|
49
163
|
}
|
|
50
164
|
|
|
51
165
|
/**
|
|
@@ -69,8 +183,21 @@ export function recomputeIndex(text: string): string {
|
|
|
69
183
|
return yamlStringify(sortKeysDeep(yamlParse(text)), { lineWidth: 0 });
|
|
70
184
|
}
|
|
71
185
|
|
|
186
|
+
/**
|
|
187
|
+
* 索引文件唯一写入口(REG-CS5 原子落盘,红队 F2):先落 `<路径>.tmp.<pid>.<rand>` 再 `rename`
|
|
188
|
+
* 覆盖。全仓(build-index / publish)只此一条写索引的路径——`grep writeFileSync` 可证没有直写。
|
|
189
|
+
* 临时文件后缀非 `.yaml`,读取器永不把它当索引;进程被杀最多残留一个 tmp,由下次重建扫掉。
|
|
190
|
+
*/
|
|
191
|
+
export function writeIndexFile(file: string, text: string): void {
|
|
192
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
193
|
+
fs.writeFileSync(tmp, text);
|
|
194
|
+
fs.renameSync(tmp, file);
|
|
195
|
+
}
|
|
196
|
+
|
|
72
197
|
export function publishCommand(dir: string, opts: PublishOptions = {}): PublishResult {
|
|
73
198
|
const cwd = opts.cwd ?? process.cwd();
|
|
199
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
200
|
+
const dryRun = opts.dryRun === true;
|
|
74
201
|
const base = dir ? path.resolve(cwd, dir) : cwd;
|
|
75
202
|
// key 解析(同 ojm sign)
|
|
76
203
|
let keyFile: string;
|
|
@@ -79,50 +206,135 @@ export function publishCommand(dir: string, opts: PublishOptions = {}): PublishR
|
|
|
79
206
|
keyFile = path.resolve(cwd, opts.key);
|
|
80
207
|
keyId = opts.id ?? "";
|
|
81
208
|
if (keyId === "")
|
|
82
|
-
throw new
|
|
209
|
+
throw new UsageError("[ojm] --key 指向自定义位置时须同时给 --id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
|
|
83
210
|
}
|
|
84
211
|
else {
|
|
85
212
|
keyId = opts.id ?? "";
|
|
86
213
|
if (keyId === "")
|
|
87
|
-
throw new
|
|
214
|
+
throw new UsageError("[ojm] 缺少签名身份:--id <publisher.id>(从 ~/.ojm/keys/<id>/ 取私钥)或 --key <pem> --id <publisher.id>。先生成:ojm keygen --id <publisher.id>");
|
|
88
215
|
keyFile = path.join(os.homedir(), ".ojm", "keys", keyId, "private.pem");
|
|
89
216
|
}
|
|
90
217
|
if (!isValidKeyId(keyId))
|
|
91
|
-
throw new
|
|
218
|
+
throw new UsageError(`[ojm] key_id 命名空间非法(C2-37):「${keyId}」`);
|
|
92
219
|
if (!fs.existsSync(keyFile))
|
|
93
220
|
throw new Error(`[ojm] 私钥文件不存在:${keyFile}(用 ojm keygen --id ${keyId} 生成,或 --key 指定)`);
|
|
94
221
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
222
|
+
const catalogPath = path.join(base, "catalog.yaml");
|
|
223
|
+
if (!fs.existsSync(catalogPath))
|
|
224
|
+
throw new Error(`[ojm] 发布目录须含 catalog.yaml(三级索引:catalog.yaml + shards/<n>.yaml):${base}`);
|
|
225
|
+
const catalogRaw = recomputeIndex(fs.readFileSync(catalogPath, "utf8"));
|
|
226
|
+
const seq = resolveIndexSeq(catalogRaw);
|
|
99
227
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
228
|
+
// REG-CS5:分片代次由 catalog 的 `shards` 钉值承载;逐片与盘上 .sig 交叉核对——
|
|
229
|
+
// 对不上即是中间态(源正在重建 / 索引被手改),不替它签字(index_generation_mismatch)
|
|
230
|
+
const shardsDir = path.join(base, "shards");
|
|
231
|
+
const pins = shardPinsOf(catalogRaw);
|
|
232
|
+
const shardNos = [...pins.keys()].sort((a, b) => a - b);
|
|
233
|
+
const shardTexts = new Map<number, string>();
|
|
234
|
+
for (const no of shardNos) {
|
|
235
|
+
const shardPath = path.join(shardsDir, `${no}.yaml`);
|
|
236
|
+
if (!fs.existsSync(shardPath))
|
|
237
|
+
throw new Error(`[ojm] catalog 钉了 shards/${no}.yaml 但文件不存在:${base}——先跑 ojm market build-index 重建同一代索引再签`);
|
|
238
|
+
const text = recomputeIndex(fs.readFileSync(shardPath, "utf8"));
|
|
239
|
+
const sigPath = `${shardPath}.sig`;
|
|
240
|
+
if (fs.existsSync(sigPath)) {
|
|
241
|
+
let sig: { seq?: unknown };
|
|
242
|
+
try {
|
|
243
|
+
sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { seq?: unknown };
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw new Error(`[ojm] shards/${no}.yaml.sig 不可解析,无法核对代次——先跑 ojm market build-index 重建同一代索引再签`);
|
|
247
|
+
}
|
|
248
|
+
if (sig?.seq !== pins.get(no))
|
|
249
|
+
throw new Error(`[ojm] shards/${no}.yaml 签名是第 ${String(sig?.seq ?? "无")} 代,catalog 钉的是第 ${pins.get(no)} 代(index_generation_mismatch)——先跑 ojm market build-index 重建同一代索引再签`);
|
|
250
|
+
}
|
|
251
|
+
shardTexts.set(no, text);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// REG-CS1/CS2:代次与时效进 catalog 正文(分片正文是纯内容,代次只由 .sig + catalog 钉值携带)。
|
|
255
|
+
// 补签既有索引时沿用其时效(重签不改时效语义),仅在缺失/非法时按当前时刻补盖。
|
|
256
|
+
const now = Math.floor(Date.now() / 1000);
|
|
257
|
+
const times = indexTimesOf(catalogRaw);
|
|
258
|
+
const meta = times === null ? indexMetaOf(seq, now) : { seq, issued_at: times.issued_at, expires_at: times.expires_at };
|
|
259
|
+
const catalogText = stampIndexMeta(catalogRaw, meta);
|
|
105
260
|
|
|
106
261
|
// P3-7 签名(detached .sig 侧车,与 archive .sig 同构)
|
|
107
262
|
const privateKeyPem = readPrivateKeyPem(keyFile);
|
|
108
263
|
const publicKeyHex = exportRawPublicKeyHex(privateKeyPem);
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
264
|
+
const catalogSig = signIndex("catalog", seq, catalogText, privateKeyPem, keyId);
|
|
265
|
+
const shardSigs = new Map<number, ReturnType<typeof signIndex>>();
|
|
266
|
+
for (const no of shardNos)
|
|
267
|
+
shardSigs.set(no, signIndex("shard", pins.get(no)!, shardTexts.get(no)!, privateKeyPem, keyId));
|
|
268
|
+
|
|
269
|
+
// 原子落盘,顺序固定「先分片 → 再 catalog」(catalog 是提交点;与 build-index 同一纪律)
|
|
270
|
+
// `--dry-run`:同一份计算、同一个顺序,只是不执行写——写盘清单照常返回,CI 可先比对
|
|
271
|
+
const writes: string[] = [];
|
|
272
|
+
for (const no of shardNos) {
|
|
273
|
+
const shardPath = path.join(shardsDir, `${no}.yaml`);
|
|
274
|
+
writes.push(shardPath, `${shardPath}.sig`);
|
|
275
|
+
}
|
|
276
|
+
writes.push(catalogPath, `${catalogPath}.sig`);
|
|
277
|
+
if (!dryRun) {
|
|
278
|
+
for (const no of shardNos) {
|
|
279
|
+
const shardPath = path.join(shardsDir, `${no}.yaml`);
|
|
280
|
+
writeIndexFile(shardPath, shardTexts.get(no)!);
|
|
281
|
+
writeIndexFile(`${shardPath}.sig`, `${JSON.stringify(shardSigs.get(no)!, null, 2)}\n`);
|
|
282
|
+
}
|
|
283
|
+
const catalogSigPath = `${catalogPath}.sig`;
|
|
284
|
+
writeIndexFile(catalogPath, catalogText);
|
|
285
|
+
writeIndexFile(catalogSigPath, `${JSON.stringify(catalogSig, null, 2)}\n`);
|
|
286
|
+
}
|
|
287
|
+
const catalogSigPath = `${catalogPath}.sig`;
|
|
288
|
+
const rel = (p: string) => path.relative(cwd, p);
|
|
289
|
+
if (dryRun)
|
|
290
|
+
log(`[ojm] --dry-run:只算不写;本次本应写入 ${writes.length} 个文件(第 ${seq} 代,${shardNos.length} 片)`);
|
|
291
|
+
else
|
|
292
|
+
log(`[ojm] 索引已重算并签名 → ${rel(catalogSigPath)}${shardNos.length > 0 ? ` + ${shardNos.length} 片` : ""}`);
|
|
293
|
+
log(`[ojm] 发布方 ${keyId};指纹 ${fingerprintOfPublicKey(publicKeyHex)}`);
|
|
117
294
|
|
|
118
295
|
let checkPassed: boolean | undefined;
|
|
119
296
|
if (opts.check === true) {
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
checkPassed =
|
|
123
|
-
|
|
297
|
+
const shardsOk = shardNos.every(no => verifyCanonical(publicKeyHex, indexCanonical("shard", pins.get(no)!, shardTexts.get(no)!), shardSigs.get(no)!.value));
|
|
298
|
+
const catalogOk = verifyCanonical(publicKeyHex, indexCanonical("catalog", seq, catalogText), catalogSig.value);
|
|
299
|
+
checkPassed = catalogOk && shardsOk;
|
|
300
|
+
log(checkPassed ? "[ojm] 自检通过:索引签名与内容匹配" : "[ojm] 自检失败:索引签名与内容不匹配——请检查私钥与索引后重签");
|
|
124
301
|
if (!checkPassed)
|
|
125
302
|
process.exitCode = 1;
|
|
126
303
|
}
|
|
127
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
catalogPath,
|
|
306
|
+
catalogSigPath,
|
|
307
|
+
shardPaths: shardNos.map(no => path.join(shardsDir, `${no}.yaml`)),
|
|
308
|
+
shardSigPaths: shardNos.map(no => path.join(shardsDir, `${no}.yaml.sig`)),
|
|
309
|
+
keyId,
|
|
310
|
+
publicKeyHex,
|
|
311
|
+
fingerprint: fingerprintOfPublicKey(publicKeyHex),
|
|
312
|
+
seq,
|
|
313
|
+
dryRun,
|
|
314
|
+
writes,
|
|
315
|
+
checkPassed,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** catalog 正文的片钉(`shards: {<片号>: <代次>}`;缺失/畸形 → 空表,由调用方按「无片可签」处理) */
|
|
320
|
+
export function shardPinsOf(catalogText: string): Map<number, number> {
|
|
321
|
+
const out = new Map<number, number>();
|
|
322
|
+
let doc: unknown;
|
|
323
|
+
try {
|
|
324
|
+
doc = yamlParse(catalogText);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
const shards = (doc !== null && typeof doc === "object" && !Array.isArray(doc))
|
|
330
|
+
? (doc as Record<string, unknown>).shards
|
|
331
|
+
: undefined;
|
|
332
|
+
if (shards === null || typeof shards !== "object" || Array.isArray(shards))
|
|
333
|
+
return out;
|
|
334
|
+
for (const [k, v] of Object.entries(shards as Record<string, unknown>)) {
|
|
335
|
+
const no = Number.parseInt(k, 10);
|
|
336
|
+
if (Number.isInteger(no) && no >= 1 && typeof v === "number" && Number.isInteger(v) && v >= 1)
|
|
337
|
+
out.set(no, v);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
128
340
|
}
|