@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
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// ojm registry import(REG-R11 离线导入):把离线包目录恢复到站点本地,全程不触网。
|
|
2
|
+
//
|
|
3
|
+
// 为什么是「纯本地文件系统」:R11 的主场景是「源挂了还能救」——内容源(CDN/registry 读面)大概率已离线,
|
|
4
|
+
// 但站点 admin 可直登这台机器跑命令。所以 import 不调任何网络:读 `<dir>/registry/`、验签、落盘,
|
|
5
|
+
// 把站点从本地副本救回来(与线上 fail-closed 同口径,只是动作发生在运维侧)。
|
|
6
|
+
//
|
|
7
|
+
// 三步(与线上 REG-CS1 / REG-R10 同语义,draft §3):
|
|
8
|
+
// ① 验签:catalog / 各分片 / revocations 的正文 + detached `.sig` 侧车,复用 `verifyCanonical`
|
|
9
|
+
// (alg 必须 Ed25519、pubkey 在**已登记的 active 索引 key** 集合里、Ed25519 验签通过)。
|
|
10
|
+
// active key 集合离线时来自本地信任库(--keys 或 ~/.ojm/registry/<origin>/index-keys.json);
|
|
11
|
+
// 取不到活跃 key 即 fail-closed(没法离线判定「这把 key 是不是 registry 的」)。
|
|
12
|
+
// ② 吊销 seq 护栏:读 revocations.yaml 的 seq,与本地产的本地账本(~/.ojm/registry/<origin>/
|
|
13
|
+
// revocation-seq.json)比对——低于已知值 / 同代内容被替换 → 拒(revocations_not_monotonic),
|
|
14
|
+
// 等价于线上 `guardAndRecordRevocationSeq`,防「把已收紧的清单回退成旧的」。
|
|
15
|
+
// ③ 落盘:把 `registry/` 内容拷进站点本地 `<cdn>/registry/`(public)或 `--out` 指定的本地索引目录,
|
|
16
|
+
// **不重新渲染**,用包内原始正文保证字节一致(与 `exportStaticIndex` 同纪律)。`--dry-run` 全程零写入。
|
|
17
|
+
//
|
|
18
|
+
// 判别码与线上 S5-3 同词汇(便于排障一眼归并):index_sig_missing / index_sig_mismatch /
|
|
19
|
+
// index_key_mismatch / index_generation_mismatch / revocations_sig_missing / revocations_sig_mismatch /
|
|
20
|
+
// revocations_not_monotonic / revocation_state_corrupt / offline_format / static_mount_missing。
|
|
21
|
+
import { Buffer } from "node:buffer";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
|
|
28
|
+
import { UsageError } from "./exit-codes";
|
|
29
|
+
import { indexCanonical, revocationCanonical, shardPinsOf } from "./publish";
|
|
30
|
+
import { inspectSidecar } from "./registry-sidecar";
|
|
31
|
+
|
|
32
|
+
/** registry 静态载体在站点 /cdn 下的子目录(与 oj-registry 的 REGISTRY_SUBDIR 逐字一致) */
|
|
33
|
+
export const REGISTRY_SUBDIR = "registry";
|
|
34
|
+
/** 站点既有静态目录挂载前缀(config.yaml mounts[].prefix),registry 不新增 mounts */
|
|
35
|
+
export const STATIC_MOUNT_PREFIX = "/cdn";
|
|
36
|
+
|
|
37
|
+
export class RegistryImportError extends Error {
|
|
38
|
+
constructor(readonly code: string, message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "RegistryImportError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 已登记的 active 索引 key(离线信任库条目) */
|
|
45
|
+
export interface IndexKeyEntry {
|
|
46
|
+
key_id: string
|
|
47
|
+
public_key: string
|
|
48
|
+
status?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RegistryImportOptions {
|
|
52
|
+
/** 离线包根目录(含 `registry/` 子目录) */
|
|
53
|
+
dir: string
|
|
54
|
+
/** 站点数据根:读 config.yaml 的 /cdn 挂载,落到 `<web>/registry/`(public 载体) */
|
|
55
|
+
site?: string
|
|
56
|
+
/** 显式目标目录(覆盖 --site 的发现;private 站点把本地索引目录直接传这里) */
|
|
57
|
+
out?: string
|
|
58
|
+
/** active 索引 key 集合 JSON 路径(数组:{key_id, public_key, status});缺省从本地信任库取 */
|
|
59
|
+
keys?: string
|
|
60
|
+
/** 本地 seq 账本源标识(多源导入各记各的);缺省取 <dir>/source.json 的 origin,再退 offline:<绝对目录> */
|
|
61
|
+
registry?: string
|
|
62
|
+
/** 全程零写入,只验证 + 列出本应写入的文件 */
|
|
63
|
+
dryRun?: boolean
|
|
64
|
+
/** 机器可读输出 */
|
|
65
|
+
json?: boolean
|
|
66
|
+
/** 测试注入;缺省 process.cwd() */
|
|
67
|
+
cwd?: string
|
|
68
|
+
/** 测试注入 home;缺省 os.homedir() */
|
|
69
|
+
home?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ImportedFile {
|
|
73
|
+
/** 包内相对 registry/ 的路径 */
|
|
74
|
+
rel: string
|
|
75
|
+
/** 落盘目标绝对路径(dry-run 下是本应写入的路径) */
|
|
76
|
+
dest: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RegistryImportResult {
|
|
80
|
+
/** 通过全部校验(含 seq 护栏) */
|
|
81
|
+
ok: boolean
|
|
82
|
+
code?: string
|
|
83
|
+
message: string
|
|
84
|
+
/** 吊销清单 seq(通关后) */
|
|
85
|
+
revocationSeq: number | null
|
|
86
|
+
/** 本应/已落盘的文件(按写入顺序) */
|
|
87
|
+
written: ImportedFile[]
|
|
88
|
+
/** 验签扫过的文件数(catalog + 分片 + revocations) */
|
|
89
|
+
verified: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sha256Hex(text: string): string {
|
|
93
|
+
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function asRecord(v: unknown, what: string): Record<string, unknown> {
|
|
97
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
98
|
+
throw new UsageError(`[ojm] ${what} 必须是对象`);
|
|
99
|
+
return v as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readText(file: string): string | null {
|
|
103
|
+
try {
|
|
104
|
+
return fs.readFileSync(file, "utf8");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 读本地 active 索引 key 集合:--keys > ~/.ojm/registry/<origin>/index-keys.json */
|
|
112
|
+
function loadActiveKeys(opts: RegistryImportOptions, origin: string, home: string): Set<string> {
|
|
113
|
+
let raw: string | null = null;
|
|
114
|
+
const tried: string[] = [];
|
|
115
|
+
if (opts.keys !== undefined) {
|
|
116
|
+
raw = readText(path.resolve(opts.cwd ?? process.cwd(), opts.keys));
|
|
117
|
+
tried.push(opts.keys);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const store = path.join(home, ".ojm", "registry", origin, "index-keys.json");
|
|
121
|
+
raw = readText(store);
|
|
122
|
+
tried.push(store);
|
|
123
|
+
}
|
|
124
|
+
if (raw === null)
|
|
125
|
+
throw new RegistryImportError("index_key_mismatch", `离线验签需要 active 索引 key 集合,但取不到(试过:${tried.join("、")})。用 --keys 指定,或先把站点已登记的 active 索引 key 落到本地信任库。`);
|
|
126
|
+
let doc: unknown;
|
|
127
|
+
try {
|
|
128
|
+
doc = JSON.parse(raw);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new UsageError(`[ojm] key 集合不是合法 JSON:${tried[tried.length - 1]}`);
|
|
132
|
+
}
|
|
133
|
+
if (!Array.isArray(doc))
|
|
134
|
+
throw new UsageError("[ojm] key 集合顶层须为数组:[{key_id, public_key, status}]");
|
|
135
|
+
const set = new Set<string>();
|
|
136
|
+
for (const e of doc) {
|
|
137
|
+
const entry = asRecord(e, "key 集合元素");
|
|
138
|
+
const pk = String(entry.public_key ?? "");
|
|
139
|
+
const status = entry.status === undefined ? "active" : String(entry.status);
|
|
140
|
+
if (/^[0-9a-f]+$/i.test(pk) && status === "active")
|
|
141
|
+
set.add(pk.toLowerCase());
|
|
142
|
+
}
|
|
143
|
+
if (set.size === 0)
|
|
144
|
+
throw new RegistryImportError("index_key_mismatch", "key 集合里没有任何 active 索引 key(status 必须为 \"active\")——无法离线判定签名公钥归属。");
|
|
145
|
+
return set;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type VerifyOutcome = { ok: true, seq: number } | { ok: false, code: string, message: string };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 验一条索引/吊销正文:alg 必须 Ed25519、pubkey 在 active 集合、Ed25519 验签通过。
|
|
152
|
+
* `canonFn` 按种类拼规范串(index:<kind>:<seq>:<sha> 或 revocations:<seq>:<sha>)。
|
|
153
|
+
* 判定逻辑在 `registry-sidecar`(与 `ojm registry export` 共用,避免「导出的包自己导不进去」)。
|
|
154
|
+
*/
|
|
155
|
+
function verifyOne(activeKeys: Set<string>, rel: string, body: string, sigText: string, canonFn: (seq: number, text: string) => string): VerifyOutcome {
|
|
156
|
+
if (sigText.trim() === "")
|
|
157
|
+
return { ok: false, code: "index_sig_missing", message: `${rel} 缺签名侧车(${rel}.sig)——完整性无保障,已拒载` };
|
|
158
|
+
const verdict = inspectSidecar({ rel, body, sigText, canon: canonFn, isTrusted: pk => activeKeys.has(pk) });
|
|
159
|
+
return verdict.ok && verdict.seq !== null
|
|
160
|
+
? { ok: true, seq: verdict.seq }
|
|
161
|
+
: { ok: false, code: verdict.code ?? "index_sig_mismatch", message: verdict.message ?? `${rel} 验签不过` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 本地吊销 seq 账本(~/.ojm/registry/<origin>/revocation-seq.json) */
|
|
165
|
+
interface SeqLedger { max_seq: number, content_sha: string }
|
|
166
|
+
|
|
167
|
+
function ledgerPath(origin: string, home: string): string {
|
|
168
|
+
return path.join(home, ".ojm", "registry", origin, "revocation-seq.json");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 账本状态:缺失(首次导入)/ 合法 / 损坏(存在但解析或字段非法) */
|
|
172
|
+
type LedgerState = SeqLedger | null | "corrupt";
|
|
173
|
+
|
|
174
|
+
function readLedger(origin: string, home: string): LedgerState {
|
|
175
|
+
const raw = readText(ledgerPath(origin, home));
|
|
176
|
+
if (raw === null)
|
|
177
|
+
return null;
|
|
178
|
+
let doc: unknown;
|
|
179
|
+
try {
|
|
180
|
+
doc = JSON.parse(raw);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return "corrupt"; // 损坏账本必须 fail-closed,不能当成「无记录」放行
|
|
184
|
+
}
|
|
185
|
+
const r = asRecord(doc, "revocation 账本");
|
|
186
|
+
const maxSeq = Number(r.max_seq);
|
|
187
|
+
const contentSha = String(r.content_sha ?? "");
|
|
188
|
+
if (!Number.isInteger(maxSeq) || maxSeq < 0)
|
|
189
|
+
return "corrupt";
|
|
190
|
+
return { max_seq: maxSeq, content_sha: contentSha };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeLedger(origin: string, home: string, ledger: SeqLedger): void {
|
|
194
|
+
const file = ledgerPath(origin, home);
|
|
195
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
196
|
+
fs.writeFileSync(file, `${JSON.stringify(ledger)}\n`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 解析 <dir>/source.json 的 origin(多源导入用,区分 seq 账本) */
|
|
200
|
+
function resolveOrigin(opts: RegistryImportOptions, pkgDir: string, cwd: string): string {
|
|
201
|
+
if (opts.registry !== undefined && opts.registry.trim() !== "")
|
|
202
|
+
return opts.registry.trim();
|
|
203
|
+
const src = readText(path.join(pkgDir, "source.json"));
|
|
204
|
+
if (src !== null) {
|
|
205
|
+
try {
|
|
206
|
+
const doc = JSON.parse(src) as Record<string, unknown>;
|
|
207
|
+
const o = doc.origin;
|
|
208
|
+
if (typeof o === "string" && o.trim() !== "")
|
|
209
|
+
return o.trim();
|
|
210
|
+
}
|
|
211
|
+
catch { /* 坏 source.json 退默认 */ }
|
|
212
|
+
}
|
|
213
|
+
return `offline:${path.resolve(cwd, opts.dir)}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 发现落盘目标:--out > --site 的 /cdn 挂载;private 站点(无挂载)必须给 --out */
|
|
217
|
+
function resolveTarget(opts: RegistryImportOptions): string {
|
|
218
|
+
if (opts.out !== undefined)
|
|
219
|
+
return path.resolve(opts.cwd ?? process.cwd(), opts.out);
|
|
220
|
+
if (opts.site === undefined)
|
|
221
|
+
throw new UsageError("[ojm] 用法:ojm registry import <dir> --site <站点数据根> [--out <目标目录>] [--keys <active索引key.json>] [--dry-run] [--json]\n public 站点用 --site(自动落到 <cdn>/registry);private 站点没有 /cdn 挂载,请用 --out 直接指定本地索引目录。");
|
|
222
|
+
const cfgPath = path.join(opts.site, "config.yaml");
|
|
223
|
+
const cfgText = readText(cfgPath);
|
|
224
|
+
if (cfgText === null)
|
|
225
|
+
throw new UsageError(`[ojm] 站点数据根没有 config.yaml:${cfgPath}`);
|
|
226
|
+
// 同步解析 config.yaml(轻量:只取 mounts,不引入 yaml 依赖)
|
|
227
|
+
const mounts = extractMounts(cfgText);
|
|
228
|
+
const webDir = mounts.find(m => m.prefix === STATIC_MOUNT_PREFIX && typeof m.web === "string")?.web;
|
|
229
|
+
if (webDir === undefined)
|
|
230
|
+
throw new RegistryImportError("static_mount_missing", `站点 ${opts.site} 没有 ${STATIC_MOUNT_PREFIX} 静态挂载,离线包无法落静态目录(private 站点请用 --out 指定本地索引目录,registry 不新增 mounts)`);
|
|
231
|
+
return path.join(webDir.replace(/\/+$/, ""), REGISTRY_SUBDIR);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 极简 YAML 提取 mounts[].{prefix, web}(只吃我们关心的两个键,不引入 yaml 依赖) */
|
|
235
|
+
function extractMounts(text: string): Array<{ prefix?: string, web?: string }> {
|
|
236
|
+
const mounts: Array<{ prefix?: string, web?: string }> = [];
|
|
237
|
+
// mounts: 之后的缩进块;逐行匹配 prefix:/ web:
|
|
238
|
+
const lines = text.split("\n");
|
|
239
|
+
let inMounts = false;
|
|
240
|
+
let current: { prefix?: string, web?: string } | null = null;
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
const trimmed = line.trim();
|
|
243
|
+
if (/^mounts\s*:/.test(trimmed)) {
|
|
244
|
+
inMounts = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (inMounts) {
|
|
248
|
+
if (/^\s*-\s+/.test(line) && current !== null) {
|
|
249
|
+
mounts.push(current);
|
|
250
|
+
current = {};
|
|
251
|
+
}
|
|
252
|
+
const pre = line.match(/prefix\s*:\s*(\S+)/);
|
|
253
|
+
const web = line.match(/web\s*:\s*(\S+)/);
|
|
254
|
+
if (pre || web) {
|
|
255
|
+
current = current ?? {};
|
|
256
|
+
if (pre)
|
|
257
|
+
current.prefix = pre[1];
|
|
258
|
+
if (web)
|
|
259
|
+
current.web = web[1].replace(/['"]/g, "");
|
|
260
|
+
}
|
|
261
|
+
// 退出 mounts 块(遇到非缩进的顶层键)
|
|
262
|
+
if (/^\S/.test(line) && !trimmed.startsWith("mounts")) {
|
|
263
|
+
if (current !== null)
|
|
264
|
+
mounts.push(current);
|
|
265
|
+
current = null;
|
|
266
|
+
inMounts = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (current !== null)
|
|
271
|
+
mounts.push(current);
|
|
272
|
+
return mounts;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 业务命令体。返回结果(测试/CI 断言用);不打印、不 exit——输出与退出码归 cli.ts。
|
|
277
|
+
* `--dry-run` 只验证 + 列出本应写入的文件,零落盘。
|
|
278
|
+
*/
|
|
279
|
+
export function registryImportCommand(opts: RegistryImportOptions): RegistryImportResult {
|
|
280
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
281
|
+
const home = opts.home ?? os.homedir();
|
|
282
|
+
if (!opts.dir)
|
|
283
|
+
throw new UsageError("ojm registry import <dir> [--site <站点数据根>] [--out <目标目录>] [--keys <active索引key.json>] [--dry-run] [--json]");
|
|
284
|
+
|
|
285
|
+
const pkgDir = path.resolve(cwd, opts.dir);
|
|
286
|
+
const regDir = path.join(pkgDir, REGISTRY_SUBDIR);
|
|
287
|
+
if (!fs.existsSync(regDir))
|
|
288
|
+
throw new RegistryImportError("offline_format", `离线包缺 ${REGISTRY_SUBDIR}/ 子目录:${regDir}(离线包结构见 REG-R11 草案:<dir>/registry/{catalog.yaml(+.sig), shards/<n>.yaml(+.sig), revocations.yaml(+.sig)})`);
|
|
289
|
+
|
|
290
|
+
const origin = resolveOrigin(opts, pkgDir, cwd);
|
|
291
|
+
const activeKeys = loadActiveKeys(opts, origin, home);
|
|
292
|
+
|
|
293
|
+
const written: ImportedFile[] = [];
|
|
294
|
+
let verified = 0;
|
|
295
|
+
|
|
296
|
+
// ① catalog
|
|
297
|
+
const catalogBody = readText(path.join(regDir, "catalog.yaml"));
|
|
298
|
+
if (catalogBody === null)
|
|
299
|
+
throw new RegistryImportError("offline_format", `离线包缺总目录 ${REGISTRY_SUBDIR}/catalog.yaml`);
|
|
300
|
+
const catalogSig = readText(path.join(regDir, "catalog.yaml.sig"));
|
|
301
|
+
if (catalogSig === null)
|
|
302
|
+
throw new RegistryImportError("index_sig_missing", "离线包总目录缺签名侧车 catalog.yaml.sig");
|
|
303
|
+
const catalogVerify = verifyOne(activeKeys, "catalog.yaml", catalogBody, catalogSig, (seq, t) => indexCanonical("catalog", seq, t));
|
|
304
|
+
if (!catalogVerify.ok)
|
|
305
|
+
throw new RegistryImportError(catalogVerify.code!, catalogVerify.message!);
|
|
306
|
+
verified++;
|
|
307
|
+
|
|
308
|
+
// ① 各分片(按 catalog 钉值)
|
|
309
|
+
let pins: Map<number, number>;
|
|
310
|
+
try {
|
|
311
|
+
pins = shardPinsOf(catalogBody);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
throw new RegistryImportError("offline_format", "离线包总目录不是合法索引 YAML(无法解析分片钉值)");
|
|
315
|
+
}
|
|
316
|
+
if (pins.size === 0)
|
|
317
|
+
throw new RegistryImportError("index_sig_mismatch", "离线包总目录的分片钉值为空——结构破损,已拒载");
|
|
318
|
+
for (const no of [...pins.keys()].sort((a, b) => a - b)) {
|
|
319
|
+
const shardBody = readText(path.join(regDir, "shards", `${no}.yaml`));
|
|
320
|
+
if (shardBody === null)
|
|
321
|
+
throw new RegistryImportError("index_sig_missing", `离线包缺分片 shards/${no}.yaml(钉值声明了却找不到)——结构破损,已拒载`);
|
|
322
|
+
const shardSig = readText(path.join(regDir, "shards", `${no}.yaml.sig`));
|
|
323
|
+
if (shardSig === null)
|
|
324
|
+
throw new RegistryImportError("index_sig_missing", `离线包分片 shards/${no}.yaml 缺签名侧车——完整性无保障,已拒载`);
|
|
325
|
+
const shardVerify = verifyOne(activeKeys, `shards/${no}.yaml`, shardBody, shardSig, (seq, t) => indexCanonical("shard", seq, t));
|
|
326
|
+
if (!shardVerify.ok)
|
|
327
|
+
throw new RegistryImportError(shardVerify.code!, shardVerify.message!);
|
|
328
|
+
const sigSeq = shardVerify.seq;
|
|
329
|
+
if (sigSeq !== pins.get(no))
|
|
330
|
+
throw new RegistryImportError("index_generation_mismatch", `离线包分片 shards/${no}.yaml 签名是第 ${sigSeq} 代,总目录钉的是第 ${pins.get(no)} 代——源正在重建索引,已拒载`);
|
|
331
|
+
verified++;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ①+② revocations
|
|
335
|
+
let revocationSeq: number | null = null;
|
|
336
|
+
const revBody = readText(path.join(regDir, "revocations.yaml"));
|
|
337
|
+
const contentSha = revBody === null ? "" : sha256Hex(revBody);
|
|
338
|
+
if (revBody !== null) {
|
|
339
|
+
const revSig = readText(path.join(regDir, "revocations.yaml.sig"));
|
|
340
|
+
if (revSig === null)
|
|
341
|
+
throw new RegistryImportError("revocations_sig_missing", "离线包吊销清单缺签名侧车 revocations.yaml.sig——空清单也必须签,已拒载");
|
|
342
|
+
const revVerify = verifyOne(activeKeys, "revocations.yaml", revBody, revSig, (seq, t) => revocationCanonical(seq, t));
|
|
343
|
+
if (!revVerify.ok)
|
|
344
|
+
throw new RegistryImportError(revVerify.code!, revVerify.message!);
|
|
345
|
+
revocationSeq = revVerify.seq;
|
|
346
|
+
verified++;
|
|
347
|
+
// ② seq 护栏(本地账本)
|
|
348
|
+
const ledger = readLedger(origin, home);
|
|
349
|
+
if (ledger === "corrupt")
|
|
350
|
+
throw new RegistryImportError("revocations_not_monotonic", `本源吊销账本损坏(无法读取已记录代次)——为防黑名单被回退,已拒载;请人工检查 ${ledgerPath(origin, home)}`);
|
|
351
|
+
if (ledger !== null && revocationSeq < ledger.max_seq)
|
|
352
|
+
throw new RegistryImportError("revocations_not_monotonic", `离线包吊销清单是第 ${revocationSeq} 代,本源已见过第 ${ledger.max_seq} 代或同代内容被替换/缩水(revocations_not_monotonic)——清单被回退,已拒载`);
|
|
353
|
+
if (ledger !== null && revocationSeq === ledger.max_seq && contentSha !== ledger.content_sha)
|
|
354
|
+
throw new RegistryImportError("revocations_not_monotonic", `离线包吊销清单仍是第 ${revocationSeq} 代但正文指纹变了(content_sha 不符)——同代清空黑名单被拦截,已拒载`);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// 无 revocations.yaml:记录一次「无清单」导入(seq=0),确保后续更高代不被静默当成首次。
|
|
358
|
+
const ledger = readLedger(origin, home);
|
|
359
|
+
if (ledger === "corrupt")
|
|
360
|
+
throw new RegistryImportError("revocations_not_monotonic", `本源吊销账本损坏——离线包无吊销清单时无法确认未清空黑名单,已拒载;请人工检查 ${ledgerPath(origin, home)}`);
|
|
361
|
+
if (ledger !== null && ledger.max_seq > 0)
|
|
362
|
+
throw new RegistryImportError("revocations_not_monotonic", `本源已记录过吊销清单(第 ${ledger.max_seq} 代),离线包却不含清单(等于把黑名单清空),已拒载`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ③ 落盘(public 静态 dir 或 --out;不重渲染,原样拷贝)
|
|
366
|
+
const target = resolveTarget(opts);
|
|
367
|
+
const f = (rel: string): ImportedFile => ({ rel, dest: path.join(target, rel) });
|
|
368
|
+
const plan: ImportedFile[] = [];
|
|
369
|
+
plan.push(f("catalog.yaml"));
|
|
370
|
+
plan.push(f("catalog.yaml.sig"));
|
|
371
|
+
if (revBody !== null) {
|
|
372
|
+
plan.push(f("revocations.yaml"));
|
|
373
|
+
const revSig = readText(path.join(regDir, "revocations.yaml.sig"));
|
|
374
|
+
if (revSig !== null)
|
|
375
|
+
plan.push(f("revocations.yaml.sig"));
|
|
376
|
+
}
|
|
377
|
+
// 分片:只拷贝 catalog 钉值且已验签的分片,杜绝包内夹带的未验签分片落到服役目录
|
|
378
|
+
for (const no of [...pins.keys()].sort((a, b) => a - b)) {
|
|
379
|
+
plan.push(f(`shards/${no}.yaml`));
|
|
380
|
+
const sig = readText(path.join(regDir, "shards", `${no}.yaml.sig`));
|
|
381
|
+
if (sig !== null)
|
|
382
|
+
plan.push(f(`shards/${no}.yaml.sig`));
|
|
383
|
+
}
|
|
384
|
+
// 包体(平铺):<slug>-<version>.tgz[+.sig]
|
|
385
|
+
for (const name of fs.readdirSync(regDir).filter(n => n.endsWith(".tgz"))) {
|
|
386
|
+
plan.push(f(name));
|
|
387
|
+
const sig = readText(path.join(regDir, `${name}.sig`));
|
|
388
|
+
if (sig !== null)
|
|
389
|
+
plan.push(f(`${name}.sig`));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (opts.dryRun === true) {
|
|
393
|
+
return { ok: true, message: `[ojm] dry-run:校验通过(${verified} 个文件已验签${revocationSeq !== null ? `,吊销清单第 ${revocationSeq} 代` : ",无吊销清单"}),本应写入 ${plan.length} 个文件到 ${target}`, revocationSeq, written: plan, verified };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fs.mkdirSync(target, { recursive: true });
|
|
397
|
+
// 先清旧的 registry/ 残留,保证「落盘 = 包内原始正文」而非增量混入
|
|
398
|
+
for (const old of fs.existsSync(target) ? fs.readdirSync(target) : []) {
|
|
399
|
+
fs.rmSync(path.join(target, old), { recursive: true, force: true });
|
|
400
|
+
}
|
|
401
|
+
for (const item of plan) {
|
|
402
|
+
fs.mkdirSync(path.dirname(item.dest), { recursive: true });
|
|
403
|
+
fs.copyFileSync(path.join(regDir, item.rel), item.dest);
|
|
404
|
+
written.push(item);
|
|
405
|
+
}
|
|
406
|
+
// 落盘成功后推进本地 seq 账本(guard 已判过,这里只记)
|
|
407
|
+
writeLedger(origin, home, { max_seq: revocationSeq ?? 0, content_sha: contentSha });
|
|
408
|
+
|
|
409
|
+
return { ok: true, message: `[ojm] 离线包已导入:${written.length} 个文件写入 ${target}${revocationSeq !== null ? `,吊销清单第 ${revocationSeq} 代` : ",无吊销清单"}`, revocationSeq, written, verified };
|
|
410
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 索引 / 吊销正文的 detached 侧车校验——REG-CS1(索引)与 REG-R10(吊销清单)共用的唯一判定点。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:`ojm registry export`(产离线包)与 `ojm registry import`(吃离线包)必须对
|
|
4
|
+
// 「一份侧车算不算数」给同一个答案。两边各写一份校验,一旦口径漂移就会出现最难查的那类故障——
|
|
5
|
+
// **导出的包自己导不进去**:导出侧松一点,问题拖到运维救站时才炸。
|
|
6
|
+
//
|
|
7
|
+
// 本模块只管**自洽**(alg / 公钥形态 / seq / base64 / Ed25519 验签);
|
|
8
|
+
// **信任**(这把公钥是不是本站登记的 active 索引 key)由调用方注入 `isTrusted`:
|
|
9
|
+
// export 侧手上没有本地信任库,只做自洽(信任判定留给真正落盘的 import / 安装侧);
|
|
10
|
+
// import 侧**必须**给 isTrusted,否则等于绕过 REG-CS1。
|
|
11
|
+
//
|
|
12
|
+
// 判别码沿用线上词汇(排障一眼归并):index_sig_mismatch(形态/验签不符)、index_key_mismatch(公钥不受信)。
|
|
13
|
+
import { verifyCanonical } from "./lifecycle/sign";
|
|
14
|
+
|
|
15
|
+
/** 侧车唯一支持的签名算法(与服务端 verifyEd25519 同口径) */
|
|
16
|
+
export const SIDECAR_ALG = "Ed25519";
|
|
17
|
+
|
|
18
|
+
const HEX_RE = /^[0-9a-f]+$/i;
|
|
19
|
+
const BASE64_RE = /^[A-Z0-9+/]+={0,2}$/i;
|
|
20
|
+
|
|
21
|
+
export interface SidecarVerdict {
|
|
22
|
+
ok: boolean
|
|
23
|
+
code?: string
|
|
24
|
+
message?: string
|
|
25
|
+
/** 侧车声明的代次(形态合法时才有值) */
|
|
26
|
+
seq: number | null
|
|
27
|
+
/** 侧车声明的公钥(小写 hex;形态合法时才有值) */
|
|
28
|
+
publicKey: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SidecarInput {
|
|
32
|
+
/** 展示用文件名(`catalog.yaml` / `shards/2.yaml` / `revocations.yaml`) */
|
|
33
|
+
rel: string
|
|
34
|
+
/** 被签的正文(逐字节原文) */
|
|
35
|
+
body: string
|
|
36
|
+
/** 侧车 JSON 文本 */
|
|
37
|
+
sigText: string
|
|
38
|
+
/** 按种类拼规范串:`index:<kind>:<seq>:<sha256>` 或 `revocations:<seq>:<sha256>` */
|
|
39
|
+
canon: (seq: number, text: string) => string
|
|
40
|
+
/** 信任判定:公钥是否属于本站登记的 active 索引 key;缺省 = 只验自洽不验归属 */
|
|
41
|
+
isTrusted?: (publicKeyHex: string) => boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 校验一份侧车。**空侧车不在这里判**(缺文件 / 空正文的码因种类而异,由调用方先判)。
|
|
46
|
+
* 坏 JSON 也算 `index_sig_mismatch`:这是数据破损,不是命令行用法问题。
|
|
47
|
+
*/
|
|
48
|
+
export function inspectSidecar(input: SidecarInput): SidecarVerdict {
|
|
49
|
+
const { rel, body, sigText, canon, isTrusted } = input;
|
|
50
|
+
|
|
51
|
+
let doc: unknown;
|
|
52
|
+
try {
|
|
53
|
+
doc = JSON.parse(sigText);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 的 .sig 不是合法 JSON`, seq: null, publicKey: null };
|
|
57
|
+
}
|
|
58
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
59
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 的 .sig 必须是对象(六字段:alg/key_id/public_key/value/canon/seq)`, seq: null, publicKey: null };
|
|
60
|
+
const sig = doc as Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
const alg = String(sig.alg ?? "");
|
|
63
|
+
if (alg !== SIDECAR_ALG)
|
|
64
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 签名算法只支持 ${SIDECAR_ALG}(收到「${alg}」)`, seq: null, publicKey: null };
|
|
65
|
+
|
|
66
|
+
const pk = String(sig.public_key ?? "").toLowerCase();
|
|
67
|
+
if (!HEX_RE.test(pk))
|
|
68
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 签名公钥不是 hex 文本`, seq: null, publicKey: null };
|
|
69
|
+
if (isTrusted !== undefined && !isTrusted(pk))
|
|
70
|
+
return { ok: false, code: "index_key_mismatch", message: `${rel} 的签名公钥不在站点已登记的 active 索引 key 集合里——无法确认它属于 registry,已拒载`, seq: null, publicKey: pk };
|
|
71
|
+
|
|
72
|
+
const seq = Number(sig.seq);
|
|
73
|
+
if (!Number.isInteger(seq) || seq < 0)
|
|
74
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 签名 seq 不合法(应为非负整数)`, seq: null, publicKey: pk };
|
|
75
|
+
|
|
76
|
+
const value = String(sig.value ?? "");
|
|
77
|
+
if (!BASE64_RE.test(value))
|
|
78
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 签名 value 不是标准 base64`, seq, publicKey: pk };
|
|
79
|
+
|
|
80
|
+
if (!verifyCanonical(pk, canon(seq, body), value))
|
|
81
|
+
return { ok: false, code: "index_sig_mismatch", message: `${rel} 验签失败——正文与签名不匹配(或被篡改),已拒载`, seq, publicKey: pk };
|
|
82
|
+
|
|
83
|
+
return { ok: true, seq, publicKey: pk };
|
|
84
|
+
}
|