@geoly-ai/skills-hub 0.1.0

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
@@ -0,0 +1,528 @@
1
+ // timestamp / snapshot 的严格解析、验签契约与验证链顺序
2
+ // 规范:02-registry.md §2(snapshot schema)、§3(timestamp schema)、§6(验证链)、
3
+ // §8(签名身份)、11-wire-contract.md、09-cli.md §6(退出码)
4
+ import {
5
+ IntegrityError, MinCliVersionError, StaleError, WireError,
6
+ REPO, CLOCK_SKEW_SECONDS, TIMESTAMP_MAX_VALIDITY_SECONDS,
7
+ parseWireJson, assertCanonicalBytes, assertExactKeys, assertUint, assertString,
8
+ assertStringArray, parseWireTime, assertAssetDigest, assertTreeDigest, sha256Of,
9
+ checkAntiReplay, advanceTrustFloor, readTrustFloor, makeFloor,
10
+ } from './trust.mjs';
11
+
12
+ export const TIMESTAMP_SCHEMA = 'geoly.skills.timestamp/1';
13
+ export const SNAPSHOT_SCHEMA = 'geoly.skills.snapshot/2';
14
+
15
+ // ── 签名身份(02-registry.md §8) ───────────────────────────────────────────
16
+ // 🔴 **精确比对**,不做前缀匹配、不做通配。两个身份**不可互换**:
17
+ // 用 release.yml 身份签出来的 timestamp 必须被拒绝,反之亦然。
18
+ export const OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
19
+ export const RELEASE_IDENTITY = `https://github.com/${REPO}/.github/workflows/release.yml@refs/heads/main`;
20
+ export const TIMESTAMP_IDENTITY = `https://github.com/${REPO}/.github/workflows/timestamp.yml@refs/heads/main`;
21
+
22
+ // ── 验签契约 ────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * 🔴 模块私有的 brand。只有 `verifySigned()` 能造出带这个 brand 的对象,
26
+ * 而它只在把 verifier 的返回值**逐项独立核对过**之后才造。
27
+ *
28
+ * 为什么要 brand:留一个可注入的 verifier 是必要的(Sigstore 验签要网络与
29
+ * TUF 根,不在本模块职责内),但「可注入」很容易滑成「假装验过」——
30
+ * 一个返回 `undefined` 或 `true` 的桩子会被当成成功。加 brand 之后,
31
+ * 下游 API 只认 `VerifiedBytes`,而它没有公开构造函数。
32
+ */
33
+ const BRAND = Symbol('geoly.verified');
34
+
35
+ class VerifiedBytes {
36
+ constructor(brand, bytes, identity, issuer) {
37
+ if (brand !== BRAND) throw new Error('VerifiedBytes 不可从外部构造');
38
+ this.bytes = bytes;
39
+ this.identity = identity;
40
+ this.issuer = issuer;
41
+ Object.freeze(this);
42
+ }
43
+ }
44
+ export { VerifiedBytes };
45
+
46
+ export function isVerified(v) { return v instanceof VerifiedBytes; }
47
+
48
+ export function assertVerified(v, where) {
49
+ if (!isVerified(v)) {
50
+ throw new IntegrityError('E_NOT_VERIFIED', `${where} 没有经过 verifySigned(),拒绝继续`);
51
+ }
52
+ return v;
53
+ }
54
+
55
+ /**
56
+ * 默认 verifier:**永远抛错**(fail-closed)。
57
+ * 不提供 `--no-verify` / `--insecure`(02-registry.md §6 末段),
58
+ * 所以这里也不提供「跳过」的默认行为 —— 没接真验签就跑不起来,而不是静默放行。
59
+ *
60
+ * 真验签器在 `src/sigstore.mjs`,接线方式:
61
+ *
62
+ * import { createSigstoreVerifier } from './sigstore.mjs';
63
+ * const verifier = createSigstoreVerifier({ trustedRoot }); // §8.1 的内置 TUF 根
64
+ * resolveCurrent({ …, verifier });
65
+ *
66
+ * 🔴 **这里故意不 import 它,也不做「找得到根就自动用」的兜底。** 两个理由:
67
+ * 1. 验签器必须带一个信任根才有意义,而根从哪来是调用方的责任
68
+ * (§8.1:随 CLI 内置;§8.2:绝不能是网络或缓存里读来的)。让本模块自己
69
+ * 去找根,等于把「用哪个根」这个安全决策藏进默认值里。
70
+ * 2. 一个可写的全局默认 verifier 就是一个 `--no-verify`:谁都能改掉它。
71
+ * 保持「必须显式注入」,注入点才始终留在代码审查看得见的地方。
72
+ */
73
+ export function defaultVerifier() {
74
+ throw new IntegrityError(
75
+ 'E_VERIFIER_MISSING',
76
+ 'Sigstore 验签器未接入:本构建无法验证签名,拒绝继续(不存在 --no-verify)。' +
77
+ '接线方式见 src/sigstore.mjs 的 createSigstoreVerifier()',
78
+ );
79
+ }
80
+
81
+ /**
82
+ * 验签并 brand。verifier 的返回值**不被信任** —— identity / issuer / 字节摘要
83
+ * 三项都在这里**独立重算并精确比对**,所以一个偷懒返回 `{ok:true}` 的 verifier
84
+ * 过不去。
85
+ *
86
+ * @param {Buffer} bytes 被签的原始字节
87
+ * @param {*} bundle Sigstore bundle
88
+ * @param {string} expectIdentity §8 的两个身份之一,精确比对
89
+ * @param {Function} verifier 必传;缺省即 `defaultVerifier`(抛错)
90
+ */
91
+ export function verifySigned({ bytes, bundle, expectIdentity, verifier = defaultVerifier, where = 'object' }) {
92
+ if (typeof verifier !== 'function') {
93
+ throw new IntegrityError('E_VERIFIER_MISSING', `${where}:未提供验签器`);
94
+ }
95
+ if (expectIdentity !== RELEASE_IDENTITY && expectIdentity !== TIMESTAMP_IDENTITY) {
96
+ throw new IntegrityError('E_UNKNOWN_IDENTITY', `${where}:期望身份不在 §8 的白名单里`);
97
+ }
98
+ const r = verifier({ bytes, bundle, expectIdentity, expectIssuer: OIDC_ISSUER });
99
+ if (!r || typeof r !== 'object' || r.ok !== true) {
100
+ throw new IntegrityError('E_SIGNATURE', `${where}:验签失败或验签器未返回 ok`);
101
+ }
102
+ // 🔴 精确比对,不做前缀匹配、不做通配(§8)
103
+ if (r.identity !== expectIdentity) {
104
+ throw new IntegrityError('E_IDENTITY_MISMATCH',
105
+ `${where}:签名身份是 ${r.identity},期望 ${expectIdentity}(两个身份不可互换)`);
106
+ }
107
+ if (r.issuer !== OIDC_ISSUER) {
108
+ throw new IntegrityError('E_ISSUER_MISMATCH', `${where}:issuer 是 ${r.issuer},期望 ${OIDC_ISSUER}`);
109
+ }
110
+ // verifier 说它验的是哪串字节 —— 必须就是我们手上这串
111
+ if (r.sha256 !== sha256Of(bytes)) {
112
+ throw new IntegrityError('E_SIGNED_BYTES_MISMATCH', `${where}:验签器验的不是我们手上的字节`);
113
+ }
114
+ return new VerifiedBytes(BRAND, bytes, r.identity, r.issuer);
115
+ }
116
+
117
+ // ── semver(禁 +build,D7) ─────────────────────────────────────────────────
118
+
119
+ const RE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:[0-9A-Za-z-]+)(?:\.[0-9A-Za-z-]+)*))?$/;
120
+
121
+ export function parseSemver(v, where) {
122
+ assertString(v, where);
123
+ if (v.includes('+')) throw new WireError('E_SEMVER_BUILD', `${where} 禁止 +build metadata(D7):${v}`);
124
+ const m = RE_SEMVER.exec(v);
125
+ if (!m) throw new WireError('E_SEMVER', `${where} 不是合法 semver 2.0.0:${v}`);
126
+ if (m[4] != null) {
127
+ for (const id of m[4].split('.')) {
128
+ // SemVer §9:数字标识符不得有前导零。`1.0.0-01` 形状像 semver 但不是。
129
+ if (/^\d+$/.test(id) && id.length > 1 && id[0] === '0') {
130
+ throw new WireError('E_SEMVER', `${where} 的预发布数字标识符有前导零:${id}`);
131
+ }
132
+ // 超过 2^53-1 的数字标识符转成 Number 会丢精度,比较结果就不可信了
133
+ if (/^\d+$/.test(id) && !Number.isSafeInteger(Number(id))) {
134
+ throw new WireError('E_SEMVER', `${where} 的预发布数字标识符超出安全整数范围:${id}`);
135
+ }
136
+ }
137
+ }
138
+ for (const part of [m[1], m[2], m[3]]) {
139
+ if (!Number.isSafeInteger(Number(part))) throw new WireError('E_SEMVER', `${where} 的版本号分量超出安全整数范围:${part}`);
140
+ }
141
+ return { major: +m[1], minor: +m[2], patch: +m[3], prerelease: m[4] ?? null, raw: v };
142
+ }
143
+
144
+ export function compareSemver(a, b) {
145
+ for (const k of ['major', 'minor', 'patch']) if (a[k] !== b[k]) return a[k] < b[k] ? -1 : 1;
146
+ if (a.prerelease === null && b.prerelease === null) return 0;
147
+ if (a.prerelease === null) return 1; // 正式版 > 预发布
148
+ if (b.prerelease === null) return -1;
149
+ const ax = a.prerelease.split('.'), bx = b.prerelease.split('.');
150
+ for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
151
+ const x = ax[i], y = bx[i];
152
+ if (x === undefined) return -1;
153
+ if (y === undefined) return 1;
154
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
155
+ if (xn && yn) { if (+x !== +y) return +x < +y ? -1 : 1; continue; }
156
+ if (xn !== yn) return xn ? -1 : 1;
157
+ if (x !== y) return x < y ? -1 : 1;
158
+ }
159
+ return 0;
160
+ }
161
+
162
+ // ── timestamp(02-registry.md §3) ──────────────────────────────────────────
163
+
164
+ const TIMESTAMP_KEYS = {
165
+ required: ['schema', 'version', 'repo', 'latest_snapshot', 'snapshot_sha256', 'min_cli_version', 'created_at', 'valid_until'],
166
+ };
167
+
168
+ /**
169
+ * 严格解析 timestamp。**不做** freshness 判定 —— 那一步要拿 `now`,
170
+ * 由 `assertFresh()` 单独做,好让 `--offline` 能把「过期」降级成 stale 标记
171
+ * 而不是硬失败(§6 第 3 步末段)。
172
+ */
173
+ export function parseTimestamp(bytes) {
174
+ const doc = parseWireJson(bytes, 'timestamp.json');
175
+ assertExactKeys(doc, TIMESTAMP_KEYS, 'timestamp.json');
176
+ if (doc.schema !== TIMESTAMP_SCHEMA) {
177
+ throw new WireError('E_SCHEMA', `timestamp 的 schema 必须是 ${TIMESTAMP_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
178
+ }
179
+ assertUint(doc.version, 'timestamp.version');
180
+ assertUint(doc.latest_snapshot, 'timestamp.latest_snapshot');
181
+ if (doc.repo !== REPO) throw new IntegrityError('E_REPO', `timestamp.repo 必须等于内置常量 ${REPO},得到 ${doc.repo}`);
182
+ assertAssetDigest(doc.snapshot_sha256, 'timestamp.snapshot_sha256');
183
+ parseSemver(doc.min_cli_version, 'timestamp.min_cli_version');
184
+ const created = parseWireTime(doc.created_at, 'timestamp.created_at');
185
+ const until = parseWireTime(doc.valid_until, 'timestamp.valid_until');
186
+
187
+ // 🔴 完整时间规则(§3):v3 只写了上界,允许负有效期与遥远未来的签发时间
188
+ const span = until - created;
189
+ if (span <= 0) throw new IntegrityError('E_TS_NEGATIVE_VALIDITY', `valid_until 不晚于 created_at(${span}s)`);
190
+ if (span > TIMESTAMP_MAX_VALIDITY_SECONDS) {
191
+ throw new IntegrityError('E_TS_VALIDITY_TOO_LONG', `有效期 ${span}s 超过 7 天上限`);
192
+ }
193
+ // 🔴 canonical 往返:timestamp 是需要逐字节复现的对象(§3 of 11-wire-contract)
194
+ assertCanonicalBytes(bytes, doc, 'timestamp.json');
195
+ return { ...doc, _created: created, _until: until, _sha256: sha256Of(bytes) };
196
+ }
197
+
198
+ /**
199
+ * freshness。🔴 **本机时钟是 freshness 的输入**(07-threat-model.md 6f)——
200
+ * 拨快 → 一切 timestamp 显得过期(fail-closed,可接受);
201
+ * 拨慢 → 过期的 timestamp 仍被接受,回放窗口被拉长。如实承认,不假装不存在。
202
+ *
203
+ * @returns {{stale:boolean}} `--offline` 时 stale 由调用方决定是否放行(需 --allow-stale)
204
+ */
205
+ export function assertFresh(ts, { now = Date.now(), offline = false } = {}) {
206
+ const nowS = Math.floor(now / 1000);
207
+ if (ts._created > nowS + CLOCK_SKEW_SECONDS) {
208
+ throw new IntegrityError('E_TS_FUTURE',
209
+ `timestamp.created_at 在未来(超出 ${CLOCK_SKEW_SECONDS}s 时钟偏移容忍)`);
210
+ }
211
+ if (nowS >= ts._until) {
212
+ if (offline) return { stale: true };
213
+ throw new StaleError(`timestamp 已于 ${ts.valid_until} 过期(退出码 8;--offline 下可用 --allow-stale)`);
214
+ }
215
+ return { stale: false };
216
+ }
217
+
218
+ /**
219
+ * `min_cli_version`(§3.1):**止血提示,不是撤销机制**。
220
+ * 它只对新版 CLI 有效 —— 已装的旧 CLI 那一版代码里根本没有这段逻辑。
221
+ */
222
+ export function assertMinCliVersion(ts, cliVersion) {
223
+ const need = parseSemver(ts.min_cli_version, 'timestamp.min_cli_version');
224
+ const have = parseSemver(cliVersion, 'cliVersion');
225
+ if (compareSemver(have, need) < 0) {
226
+ throw new MinCliVersionError(`CLI ${cliVersion} 低于 timestamp 要求的 ${ts.min_cli_version},请升级`);
227
+ }
228
+ }
229
+
230
+ // ── snapshot(02-registry.md §2) ───────────────────────────────────────────
231
+
232
+ const RE_NAMESPACE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
233
+ const RE_NAME = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
234
+ const RE_COMMIT = /^[0-9a-f]{40}$/;
235
+ const KINDS = new Set(['skill', 'pack']);
236
+ const STATUSES = new Set(['published', 'deprecated', 'yanked', 'degraded']);
237
+ /** 🔴 §2.3:submitted / in_review / approved / rejected 不进快照 */
238
+ const NON_SNAPSHOT_STATUSES = new Set(['submitted', 'in_review', 'approved', 'rejected']);
239
+
240
+ const SNAPSHOT_KEYS = { required: ['schema', 'snapshot', 'previous', 'created_at', 'repo', 'artifacts', 'yanked', 'latest'] };
241
+ const RECORD_KEYS = {
242
+ required: ['id', 'kind', 'namespace', 'name', 'version', 'path', 'tree_digest', 'asset',
243
+ 'clients', 'capabilities', 'replaces', 'conflicts', 'license', 'owner', 'provenance', 'status', 'review'],
244
+ };
245
+ const ASSET_KEYS = { required: ['file', 'sha256', 'size'] };
246
+ const OWNER_KEYS = { required: ['kind', 'login', 'id'] };
247
+ const REVIEW_KEYS = { required: ['pr', 'approved_by', 'head_sha', 'capability_tier'] };
248
+ const YANK_KEYS = { required: ['id', 'at', 'reason'], optional: ['advisory', 'superseded_by'] };
249
+ const PROV_VENDORED = {
250
+ required: ['kind', 'origin_repo', 'origin_ref', 'origin_commit', 'origin_subpath',
251
+ 'origin_tree_digest', 'license_evidence', 'imported_at', 'imported_by_pr', 'added_files'],
252
+ };
253
+ const PROV_ORIGINAL = { required: ['kind', 'author_github_id', 'submitted_by_pr'] };
254
+
255
+ function validateProvenance(p, where) {
256
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) throw new WireError('E_WIRE_TYPE', `${where} 必须是对象`);
257
+ if (p.kind === 'vendored') {
258
+ assertExactKeys(p, PROV_VENDORED, where);
259
+ assertString(p.origin_repo, `${where}.origin_repo`);
260
+ assertString(p.origin_ref, `${where}.origin_ref`);
261
+ // 🔴 origin_commit 必须是 40 位 commit SHA,不能只记 tag —— tag 可以被移动,
262
+ // 那正是「审核后换内容」的攻击路径(05-lifecycle.md §6)。
263
+ if (!RE_COMMIT.test(p.origin_commit ?? '')) {
264
+ throw new WireError('E_PROV_COMMIT', `${where}.origin_commit 必须是 40 位小写 hex,得到 ${p.origin_commit}`);
265
+ }
266
+ assertString(p.origin_subpath, `${where}.origin_subpath`);
267
+ // 🔴 它是**树**摘要,不是资产摘要 —— 必须带 `geoly-tree-v1:` 前缀(ERRATA E-8)。
268
+ // 早先用 assertAssetDigest(只接受裸 `sha256:<64hex>`),跟了 05-lifecycle.md:118
269
+ // 那个错示例。系统里有两种树算法(geoly-tree-v1 只算文件、geoly-tx-v1 还算目录项),
270
+ // 裸 sha256 分不出是哪一种;而这个值的用途正是与 treeDigest() 的输出比对。
271
+ assertTreeDigest(p.origin_tree_digest, `${where}.origin_tree_digest`);
272
+ assertString(p.license_evidence, `${where}.license_evidence`);
273
+ parseWireTime(p.imported_at, `${where}.imported_at`);
274
+ assertUint(p.imported_by_pr, `${where}.imported_by_pr`);
275
+ assertStringArray(p.added_files, `${where}.added_files`);
276
+ } else if (p.kind === 'original') {
277
+ assertExactKeys(p, PROV_ORIGINAL, where);
278
+ assertString(p.author_github_id, `${where}.author_github_id`);
279
+ assertUint(p.submitted_by_pr, `${where}.submitted_by_pr`);
280
+ } else {
281
+ throw new WireError('E_PROV_KIND', `${where}.kind 只能是 vendored / original,得到 ${JSON.stringify(p.kind)}`);
282
+ }
283
+ return p;
284
+ }
285
+
286
+ function validateRecord(r, i) {
287
+ const where = `snapshot.artifacts[${i}]`;
288
+ assertExactKeys(r, RECORD_KEYS, where);
289
+ if (!KINDS.has(r.kind)) throw new WireError('E_KIND', `${where}.kind 只能是 skill / pack,得到 ${JSON.stringify(r.kind)}`);
290
+ if (!RE_NAMESPACE.test(assertString(r.namespace, `${where}.namespace`))) {
291
+ throw new WireError('E_NAMESPACE', `${where}.namespace 不合 grammar:${r.namespace}`);
292
+ }
293
+ if (!RE_NAME.test(assertString(r.name, `${where}.name`))) {
294
+ throw new WireError('E_NAME', `${where}.name 不合 grammar:${r.name}`);
295
+ }
296
+ const sv = parseSemver(r.version, `${where}.version`);
297
+
298
+ // ArtifactId 与各字段一致(01-artifacts.md §3、§5.3 的第 6 项)
299
+ const wantId = `${r.kind}:${r.namespace}/${r.name}@${r.version}`;
300
+ if (r.id !== wantId) throw new WireError('E_ID_MISMATCH', `${where}.id 应为 ${wantId},得到 ${r.id}`);
301
+ const wantPath = `artifacts/${r.kind}s/${r.namespace}/${r.name}/${r.version}`;
302
+ if (r.path !== wantPath) throw new WireError('E_PATH_MISMATCH', `${where}.path 应为 ${wantPath},得到 ${r.path}`);
303
+
304
+ assertTreeDigest(r.tree_digest, `${where}.tree_digest`);
305
+
306
+ assertExactKeys(r.asset, ASSET_KEYS, `${where}.asset`);
307
+ assertString(r.asset.file, `${where}.asset.file`);
308
+ assertAssetDigest(r.asset.sha256, `${where}.asset.sha256`);
309
+ assertUint(r.asset.size, `${where}.asset.size`);
310
+
311
+ assertStringArray(r.clients, `${where}.clients`);
312
+ assertStringArray(r.capabilities, `${where}.capabilities`);
313
+ assertStringArray(r.replaces, `${where}.replaces`);
314
+ assertStringArray(r.conflicts, `${where}.conflicts`);
315
+ assertString(r.license, `${where}.license`);
316
+
317
+ assertExactKeys(r.owner, OWNER_KEYS, `${where}.owner`);
318
+ assertString(r.owner.kind, `${where}.owner.kind`);
319
+ assertString(r.owner.login, `${where}.owner.login`);
320
+ assertString(r.owner.id, `${where}.owner.id`);
321
+
322
+ validateProvenance(r.provenance, `${where}.provenance`);
323
+
324
+ if (NON_SNAPSHOT_STATUSES.has(r.status)) {
325
+ throw new WireError('E_STATUS_NOT_IN_SNAPSHOT', `${where}.status=${r.status} 不得进入快照(§2.3)`);
326
+ }
327
+ if (!STATUSES.has(r.status)) throw new WireError('E_STATUS', `${where}.status 不合法:${JSON.stringify(r.status)}`);
328
+
329
+ assertExactKeys(r.review, REVIEW_KEYS, `${where}.review`);
330
+ assertUint(r.review.pr, `${where}.review.pr`);
331
+ assertStringArray(r.review.approved_by, `${where}.review.approved_by`);
332
+ // review.head_sha 指向投稿 PR 的 head,早于本快照存在 → 允许(§2.1)
333
+ if (!RE_COMMIT.test(assertString(r.review.head_sha, `${where}.review.head_sha`))) {
334
+ throw new WireError('E_REVIEW_HEAD_SHA', `${where}.review.head_sha 必须是 40 位小写 hex`);
335
+ }
336
+ assertUint(r.review.capability_tier, `${where}.review.capability_tier`);
337
+
338
+ return { ...r, _semver: sv };
339
+ }
340
+
341
+ /**
342
+ * 严格解析 snapshot(§6 第 5 步)。
343
+ * 🔴 **只校验 snapshot 自身可得的数据** —— 与载荷 manifest 的六/七项绑定在第 7 步做,
344
+ * manifest 在资产内部,这里根本拿不到(v3 在这里要求校验,顺序上不可能)。
345
+ *
346
+ * @param {Buffer} bytes
347
+ * @param {{expectSnapshot:number}} opts N = timestamp.latest_snapshot
348
+ */
349
+ export function parseSnapshot(bytes, { expectSnapshot } = {}) {
350
+ const doc = parseWireJson(bytes, 'snapshot');
351
+ assertExactKeys(doc, SNAPSHOT_KEYS, 'snapshot');
352
+ if (doc.schema !== SNAPSHOT_SCHEMA) {
353
+ throw new WireError('E_SCHEMA', `snapshot 的 schema 必须是 ${SNAPSHOT_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
354
+ }
355
+ assertUint(doc.snapshot, 'snapshot.snapshot');
356
+ assertUint(doc.previous, 'snapshot.previous');
357
+ if (doc.previous >= doc.snapshot && doc.snapshot !== 0) {
358
+ throw new WireError('E_SNAPSHOT_PREV', `snapshot.previous(${doc.previous}) 必须小于 snapshot(${doc.snapshot})`);
359
+ }
360
+ parseWireTime(doc.created_at, 'snapshot.created_at');
361
+ if (doc.repo !== REPO) throw new IntegrityError('E_REPO', `snapshot.repo 必须等于内置常量 ${REPO}`);
362
+ if (expectSnapshot !== undefined && doc.snapshot !== expectSnapshot) {
363
+ throw new IntegrityError('E_SNAPSHOT_N', `snapshot=${doc.snapshot},但 timestamp.latest_snapshot=${expectSnapshot}`);
364
+ }
365
+ if (!Array.isArray(doc.artifacts)) throw new WireError('E_WIRE_TYPE', 'snapshot.artifacts 必须是数组');
366
+ if (!Array.isArray(doc.yanked)) throw new WireError('E_WIRE_TYPE', 'snapshot.yanked 必须是数组');
367
+
368
+ const records = doc.artifacts.map(validateRecord);
369
+
370
+ // 🔴 artifacts 按 id 字节序**严格升序**(§2.3)——顺序参与确定性,不符即拒
371
+ for (let i = 1; i < records.length; i++) {
372
+ const a = Buffer.from(records[i - 1].id, 'utf8'), b = Buffer.from(records[i].id, 'utf8');
373
+ const c = Buffer.compare(a, b);
374
+ if (c === 0) throw new WireError('E_ID_DUPLICATE', `snapshot.artifacts 里 id 重复:${records[i].id}`);
375
+ if (c > 0) throw new WireError('E_ARTIFACTS_ORDER', `snapshot.artifacts 未按 id 字节序升序:${records[i - 1].id} 之后是 ${records[i].id}`);
376
+ }
377
+
378
+ // yanked 列表
379
+ const byId = new Map(records.map(r => [r.id, r]));
380
+ const seenYank = new Set();
381
+ doc.yanked.forEach((y, i) => {
382
+ const where = `snapshot.yanked[${i}]`;
383
+ if (typeof y?.id === 'string') {
384
+ if (seenYank.has(y.id)) throw new WireError('E_YANK_DUPLICATE', `${where}:yanked 列表里 id 重复:${y.id}`);
385
+ seenYank.add(y.id);
386
+ }
387
+ assertExactKeys(y, YANK_KEYS, where);
388
+ assertString(y.id, `${where}.id`);
389
+ parseWireTime(y.at, `${where}.at`);
390
+ assertString(y.reason, `${where}.reason`);
391
+ if (Object.hasOwn(y, 'advisory')) assertString(y.advisory, `${where}.advisory`);
392
+ if (Object.hasOwn(y, 'superseded_by')) assertString(y.superseded_by, `${where}.superseded_by`);
393
+ const rec = byId.get(y.id);
394
+ if (!rec) throw new WireError('E_YANK_UNKNOWN', `${where}.id 不在 artifacts 里:${y.id}`);
395
+ if (rec.status !== 'yanked') {
396
+ throw new WireError('E_YANK_STATUS', `${where} 列出了 ${y.id},但它的 status 是 ${rec.status}`);
397
+ }
398
+ });
399
+ for (const r of records) {
400
+ if (r.status === 'yanked' && !doc.yanked.some(y => y.id === r.id)) {
401
+ throw new WireError('E_YANK_MISSING', `${r.id} 的 status 是 yanked,却不在 yanked 列表里`);
402
+ }
403
+ }
404
+
405
+ // 🔴 latest 投影自洽(§2.3):只列非 yank、非 prerelease、**非 degraded** 的最高版本。
406
+ // v2 会把 degraded 的最高版选成默认,然后安装必失败。
407
+ const expected = new Map();
408
+ for (const r of records) {
409
+ if (r.status === 'yanked' || r.status === 'degraded') continue;
410
+ if (r._semver.prerelease !== null) continue;
411
+ const key = `${r.kind}:${r.namespace}/${r.name}`;
412
+ const cur = expected.get(key);
413
+ if (!cur || compareSemver(r._semver, cur._semver) > 0) expected.set(key, r);
414
+ }
415
+ if (doc.latest === null || typeof doc.latest !== 'object' || Array.isArray(doc.latest)) {
416
+ throw new WireError('E_WIRE_TYPE', 'snapshot.latest 必须是对象');
417
+ }
418
+ const gotKeys = Object.keys(doc.latest).sort();
419
+ const wantKeys = [...expected.keys()].sort();
420
+ if (gotKeys.join('\n') !== wantKeys.join('\n')) {
421
+ throw new WireError('E_LATEST_KEYS',
422
+ `snapshot.latest 的键集不自洽:多了 ${gotKeys.filter(k => !expected.has(k)).join(',') || '(无)'},` +
423
+ `少了 ${wantKeys.filter(k => !gotKeys.includes(k)).join(',') || '(无)'}`);
424
+ }
425
+ for (const [k, r] of expected) {
426
+ if (doc.latest[k] !== r.version) {
427
+ throw new WireError('E_LATEST_VALUE', `snapshot.latest[${k}] 应为 ${r.version},得到 ${doc.latest[k]}`);
428
+ }
429
+ }
430
+
431
+ assertCanonicalBytes(bytes, doc, 'snapshot');
432
+ return { ...doc, artifacts: records, _sha256: sha256Of(bytes) };
433
+ }
434
+
435
+ /** 逐字节验 snapshot 的 sha256 == timestamp.snapshot_sha256(§6 第 4 步的前半) */
436
+ export function assertSnapshotDigest(bytes, expected) {
437
+ const got = sha256Of(bytes);
438
+ if (got !== expected) {
439
+ throw new IntegrityError('E_SNAPSHOT_SHA256', `snapshot 字节 sha256 是 ${got},timestamp 说应为 ${expected}`);
440
+ }
441
+ return got;
442
+ }
443
+
444
+ // ── 验证链(02-registry.md §6 第 1–6 步) ───────────────────────────────────
445
+
446
+ /**
447
+ * 🔴 顺序本身就是安全属性,所以把它写成一个函数而不是散在调用方:
448
+ *
449
+ * 1 验 timestamp 签名(identity = timestamp.yml)
450
+ * 2 严格校验 + freshness + min_cli_version
451
+ * 3 抗回滚三分支 + snapshot 单调性(对**本地 floor**)
452
+ * 4 取 snapshot → 先逐字节验 sha256 == timestamp.snapshot_sha256 → **再独立验它自己的签名**
453
+ * (identity = release.yml。两个身份不可互换)
454
+ * 5 严格解析 snapshot
455
+ * 6 原子推进 trust floor —— **此后才允许下载**
456
+ *
457
+ * 🔴 第 6 步之后返回 `redo` 时调用方必须整个重来,不得沿用已验的旧 timestamp/旧 snapshot。
458
+ * 🔴 `fetchTimestamp` / `fetchSnapshot` 命中缓存也**必须**走完同样的验签 ——
459
+ * 缓存只省网络,不省任何一次密码学校验(§9.2;本机文件可被同权限进程改写)。
460
+ */
461
+ export function resolveCurrent(opts) {
462
+ const maxAttempts = opts.maxAttempts ?? 3;
463
+ for (let attempt = 1; ; attempt++) {
464
+ const r = resolveCurrentOnce(opts);
465
+ if (r.advanced.action !== 'redo') return r;
466
+ // 🔴 磁盘 floor 在我们验证期间被别的进程推得更高。
467
+ // **不能把 redo 当成功返回** —— 调用方会拿着 `snapshot`(旧的那张)
468
+ // 继续下载安装,floor 虽然没回退,本进程仍按旧快照装东西。
469
+ // 唯一正确的动作是整个重来:重取 timestamp、重验签、重做 §6 第 3–5 步。
470
+ if (attempt >= maxAttempts) {
471
+ throw new IntegrityError('E_FLOOR_REDO',
472
+ `trust floor 连续 ${maxAttempts} 次在验证期间被推进(磁盘已到 ` +
473
+ `timestamp_version=${r.advanced.diskFloor.timestamp_version}):放弃本次解析,请重跑`);
474
+ }
475
+ }
476
+ }
477
+
478
+ function resolveCurrentOnce({
479
+ stateDir, fetchTimestamp, fetchSnapshot, verifier,
480
+ cliVersion, now = Date.now(), offline = false, allowStale = false,
481
+ }) {
482
+ // 1
483
+ const { bytes: tsBytes, bundle: tsBundle } = fetchTimestamp();
484
+ verifySigned({ bytes: tsBytes, bundle: tsBundle, expectIdentity: TIMESTAMP_IDENTITY, verifier, where: 'timestamp.json' });
485
+
486
+ // 2
487
+ const ts = parseTimestamp(tsBytes);
488
+ const fresh = assertFresh(ts, { now, offline });
489
+ if (fresh.stale && !allowStale) {
490
+ throw new StaleError('缓存中的 timestamp 已过期;--offline 下需要 --allow-stale');
491
+ }
492
+ if (cliVersion !== undefined) assertMinCliVersion(ts, cliVersion);
493
+
494
+ // 3
495
+ const floor = readTrustFloor(stateDir);
496
+ const candidate = makeFloor({
497
+ timestamp_version: ts.version,
498
+ timestamp_sha256: ts._sha256,
499
+ latest_snapshot: ts.latest_snapshot,
500
+ snapshot_sha256: ts.snapshot_sha256,
501
+ now: new Date(now),
502
+ });
503
+ checkAntiReplay(floor, candidate);
504
+
505
+ // 4
506
+ const { bytes: snapBytes, bundle: snapBundle } = fetchSnapshot(ts.latest_snapshot);
507
+ assertSnapshotDigest(snapBytes, ts.snapshot_sha256);
508
+ verifySigned({ bytes: snapBytes, bundle: snapBundle, expectIdentity: RELEASE_IDENTITY, verifier, where: `hub-${ts.latest_snapshot}.json` });
509
+
510
+ // 5
511
+ const snapshot = parseSnapshot(snapBytes, { expectSnapshot: ts.latest_snapshot });
512
+
513
+ // 6
514
+ const advanced = advanceTrustFloor(stateDir, candidate);
515
+ return { timestamp: ts, snapshot, floor: advanced.floor ?? candidate, advanced, stale: fresh.stale };
516
+ }
517
+
518
+ /**
519
+ * 历史快照的读取路径(§6.1)。🔴 与「解析当前」是**两条不同的路径**:
520
+ * 「N 小于本地 floor 即拒绝」只适用于解析当前;读历史快照 M 可以 < 当前。
521
+ * 但它**只读**:可用于验字节、取证、`--snapshot` 复现,
522
+ * ❌ 不得用它回答「现在还能不能用」——那必须查当前快照。
523
+ */
524
+ export function readHistoricalSnapshot({ bytes, bundle, verifier, expectSnapshot }) {
525
+ verifySigned({ bytes, bundle, expectIdentity: RELEASE_IDENTITY, verifier, where: `hub-${expectSnapshot}.json` });
526
+ const snap = parseSnapshot(bytes, { expectSnapshot });
527
+ return Object.freeze({ snapshot: snap, readOnly: true });
528
+ }
package/src/stats.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // 本地聚合 + 文本报表。纯函数,不碰网络。
2
+ import { readHistory } from './telemetry.mjs';
3
+
4
+ export function aggregate(events) {
5
+ const byArtifact = new Map(), byClient = new Map(), byKind = new Map();
6
+ let ok = 0, failed = 0, msTotal = 0, msCount = 0;
7
+ let first = null, last = null;
8
+ for (const e of events) {
9
+ if (!first || e.at < first) first = e.at;
10
+ if (!last || e.at > last) last = e.at;
11
+ if (e.result === 'ok') ok++; else if (e.result === 'failed') failed++;
12
+ if (typeof e.ms === 'number') { msTotal += e.ms; msCount++; }
13
+ bump(byKind, e.kind, e.result);
14
+ if (e.artifact) bump(byArtifact, e.artifact, e.result);
15
+ if (e.client) bump(byClient, e.client, e.result);
16
+ }
17
+ return {
18
+ total: events.length, ok, failed,
19
+ successRate: events.length ? ok / events.length : 0,
20
+ avgMs: msCount ? Math.round(msTotal / msCount) : null,
21
+ window: { first, last },
22
+ byArtifact: sorted(byArtifact), byClient: sorted(byClient), byKind: sorted(byKind),
23
+ };
24
+ }
25
+ function bump(m, k, result) {
26
+ const r = m.get(k) ?? { key: k, n: 0, ok: 0, failed: 0 };
27
+ r.n++; if (result === 'ok') r.ok++; else if (result === 'failed') r.failed++;
28
+ m.set(k, r);
29
+ }
30
+ const sorted = m => [...m.values()].sort((a, b) => b.n - a.n || a.key.localeCompare(b.key));
31
+
32
+ const pct = x => `${(x * 100).toFixed(1)}%`;
33
+ const pad = (s, n) => String(s).padEnd(n);
34
+ const padL = (s, n) => String(s).padStart(n);
35
+
36
+ /** 纯 ASCII 文本报表 —— 没有事件时说清楚原因,不要输出一张空表 */
37
+ export function textReport(agg) {
38
+ if (agg.total === 0) {
39
+ return '还没有埋点事件。\n跑几次 install/check 之后再看;若设了 GEOLY_TELEMETRY=0 则本地不会记录任何东西。\n';
40
+ }
41
+ const L = [];
42
+ L.push('skills-hub 本地埋点报表');
43
+ L.push('='.repeat(46));
44
+ L.push(`事件总数 ${agg.total} 成功 ${agg.ok} 失败 ${agg.failed} 成功率 ${pct(agg.successRate)}`);
45
+ if (agg.avgMs !== null) L.push(`平均耗时 ${agg.avgMs} ms`);
46
+ L.push(`时间窗口 ${agg.window.first} -> ${agg.window.last}`);
47
+ for (const [title, rows, w] of [['按制品', agg.byArtifact, 40], ['按客户端', agg.byClient, 12], ['按操作', agg.byKind, 12]]) {
48
+ if (!rows.length) continue;
49
+ L.push('', title, '-'.repeat(46));
50
+ // 表头用 ASCII:中文是双宽字符,padEnd 按码点数算会让整列错位
51
+ L.push(`${pad('', w)} ${padL('N', 6)} ${padL('OK', 6)} ${padL('FAIL', 6)}`);
52
+ for (const r of rows) L.push(`${pad(r.key.slice(0, w), w)} ${padL(r.n, 6)} ${padL(r.ok, 6)} ${padL(r.failed, 6)}`);
53
+ }
54
+ return L.join('\n') + '\n';
55
+ }
56
+
57
+ export function stats({ events = readHistory() } = {}) {
58
+ return { agg: aggregate(events), events };
59
+ }