@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
package/src/trust.mjs ADDED
@@ -0,0 +1,563 @@
1
+ // trust floor(抗回滚下限)与 wire 通用校验
2
+ // 规范:02-registry.md §5(写入规则 / 单调提交)、§6(验证链第 3、6 步)、
3
+ // 11-wire-contract.md §2(解析规则)、§3(canonical)、§5(原子写)
4
+ //
5
+ // 本模块是「信任与制品链」这一簇的地基:snapshot.mjs / attestation.mjs /
6
+ // artifact.mjs 都从这里拿 wire 校验器与错误类型,避免各写一份而分叉
7
+ // (分叉点就是绕过点 —— 01-artifacts.md §6.3 同理)。
8
+ import { createHash } from 'node:crypto';
9
+ import { readFileSync, statSync, realpathSync } from 'node:fs';
10
+ import { join, isAbsolute } from 'node:path';
11
+ import { writeAtomic } from './atomic-fs.mjs';
12
+ import { stringify } from './canonical-json.mjs';
13
+ import { acquire } from './lock.mjs';
14
+ import { assertNoSymlinkInChain } from './safe-fs.mjs';
15
+
16
+ // ── 错误类型 ────────────────────────────────────────────────────────────────
17
+ // 退出码见 09-cli.md §6。每个错误都带 `violation`:🔴 报出**具体违规项**,
18
+ // 不是笼统「验证失败」——排障时「哪一条」比「失败了」有用得多。
19
+
20
+ export class WireError extends Error {
21
+ constructor(violation, msg) {
22
+ super(`[${violation}] ${msg}`);
23
+ this.name = 'WireError';
24
+ this.violation = violation;
25
+ this.code = 1; // 解析失败
26
+ }
27
+ }
28
+
29
+ /** 完整性失败:验签失败、摘要不符、算法不认识、回滚攻击(退出码 2) */
30
+ export class IntegrityError extends Error {
31
+ constructor(violation, msg) {
32
+ super(`[${violation}] ${msg}`);
33
+ this.name = 'IntegrityError';
34
+ this.violation = violation;
35
+ this.code = 2;
36
+ }
37
+ }
38
+
39
+ /** timestamp 过期且未给 --allow-stale(退出码 8) */
40
+ export class StaleError extends Error {
41
+ constructor(msg) {
42
+ super(`[E_STALE] ${msg}`);
43
+ this.name = 'StaleError';
44
+ this.violation = 'E_STALE';
45
+ this.code = 8;
46
+ }
47
+ }
48
+
49
+ /** CLI 版本低于 timestamp 的 min_cli_version(退出码 11) */
50
+ export class MinCliVersionError extends Error {
51
+ constructor(msg) {
52
+ super(`[E_MIN_CLI_VERSION] ${msg}`);
53
+ this.name = 'MinCliVersionError';
54
+ this.violation = 'E_MIN_CLI_VERSION';
55
+ this.code = 11;
56
+ }
57
+ }
58
+
59
+ // ── wire JSON 严格解析(11-wire-contract.md §2) ────────────────────────────
60
+
61
+ export const MAX_JSON_BYTES = 8 * 1024 * 1024;
62
+ export const REPO = 'geoly-ai/skills-hub';
63
+ export const CLOCK_SKEW_SECONDS = 300; // §3:SKEW = 5 分钟
64
+ export const TIMESTAMP_MAX_VALIDITY_SECONDS = 7 * 24 * 3600;
65
+
66
+ export const RE_ASSET_SHA256 = /^sha256:[0-9a-f]{64}$/;
67
+ export const RE_TREE_DIGEST = /^geoly-tree-v1:sha256:[0-9a-f]{64}$/;
68
+ export const RE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
69
+ /** 🔴 摘要值必须自带算法标识(01-artifacts.md §6.1)。裸 hex 一律不认。 */
70
+ export const RE_ANY_DIGEST = /^([A-Za-z0-9-]+):([A-Za-z0-9-]+):([0-9a-f]+)$/;
71
+
72
+ const utf8Strict = new TextDecoder('utf-8', { fatal: true });
73
+
74
+ /**
75
+ * 🔴 自带的严格 JSON 解析器,**不用** `canonical-json.mjs` 的 `parseStrict`。
76
+ *
77
+ * 为什么不复用:那一份的重复 key 扫描比较的是**转义前的原文**,于是
78
+ *
79
+ * {"a":1,"a":2}
80
+ *
81
+ * 会被静默接受成 `{a: 2}` —— 既绕过「拒绝重复 key」,也能绕过
82
+ * `additionalProperties: false`(把一个安全字段用转义形式写第二遍,
83
+ * 旧值被后一个悄悄覆盖)。已实测复现,详见交付汇报。
84
+ *
85
+ * 这里一次遍历同时落实 §2 的全部解析规则:
86
+ * - 只接受 `(0|[1-9][0-9]*)` 形状的数字字面量(拒浮点、指数、前导零、负数、`-0`);
87
+ * - 重复 key 按**解码后**的 key 判定;
88
+ * - 字符串禁 C0/C1(**key 也判**)、禁未配对代理、禁非法转义、禁裸控制符;
89
+ * - 只接受 JSON 允许的四种空白,拒绝尾随内容。
90
+ */
91
+ export function parseWireText(text, where = 'document') {
92
+ let i = 0;
93
+ const n = text.length;
94
+
95
+ const fail = (v, m) => { throw new WireError(v, `${where} @${i}:${m}`); };
96
+
97
+ function ws() {
98
+ while (i < n) {
99
+ const c = text.charCodeAt(i);
100
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++;
101
+ else break;
102
+ }
103
+ }
104
+
105
+ function checkNoControl(s, kind) {
106
+ for (let k = 0; k < s.length; k++) {
107
+ const c = s.charCodeAt(k);
108
+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f)) {
109
+ fail('E_WIRE_CONTROL_CHAR', `${kind} 含 C0/C1 控制符 U+${c.toString(16).padStart(4, '0')}`);
110
+ }
111
+ }
112
+ }
113
+
114
+ function hex4() {
115
+ if (i + 4 > n) fail('E_WIRE_PARSE', '\\u 转义被截断');
116
+ const h = text.slice(i, i + 4);
117
+ if (!/^[0-9a-fA-F]{4}$/.test(h)) fail('E_WIRE_PARSE', `非法 \\u 转义 \\u${h}`);
118
+ i += 4;
119
+ return parseInt(h, 16);
120
+ }
121
+
122
+ function str(kind) {
123
+ if (text[i] !== '"') fail('E_WIRE_PARSE', '期待字符串');
124
+ i++;
125
+ let out = '';
126
+ for (;;) {
127
+ if (i >= n) fail('E_WIRE_PARSE', '字符串未闭合');
128
+ const ch = text[i];
129
+ const cc = text.charCodeAt(i);
130
+ if (ch === '"') { i++; break; }
131
+ if (cc < 0x20) fail('E_WIRE_CONTROL_CHAR', `${kind} 含裸控制符 U+${cc.toString(16).padStart(4, '0')}`);
132
+ if (ch !== '\\') {
133
+ // 🔴 未转义的孤立代理。`parseWireJson` 那条路上严格 UTF-8 解码已经挡掉了,
134
+ // 但 `parseWireText` 直接吃 JS 字符串时不会 —— 导出的契约得自己完整。
135
+ if (cc >= 0xd800 && cc <= 0xdbff) {
136
+ const lo = text.charCodeAt(i + 1);
137
+ if (!(lo >= 0xdc00 && lo <= 0xdfff)) fail('E_WIRE_LONE_SURROGATE', `${kind} 含未转义的孤立高位代理`);
138
+ out += text[i] + text[i + 1]; i += 2; continue;
139
+ }
140
+ if (cc >= 0xdc00 && cc <= 0xdfff) fail('E_WIRE_LONE_SURROGATE', `${kind} 含未转义的孤立低位代理`);
141
+ out += ch; i++; continue;
142
+ }
143
+ i++;
144
+ const e = text[i];
145
+ i++;
146
+ switch (e) {
147
+ case '"': out += '"'; break;
148
+ case '\\': out += '\\'; break;
149
+ case '/': out += '/'; break;
150
+ case 'b': out += '\b'; break;
151
+ case 'f': out += '\f'; break;
152
+ case 'n': out += '\n'; break;
153
+ case 'r': out += '\r'; break;
154
+ case 't': out += '\t'; break;
155
+ case 'u': {
156
+ const u = hex4();
157
+ if (u >= 0xd800 && u <= 0xdbff) {
158
+ // 🔴 BMP 外必须写成完整代理对;lone surrogate → 拒绝整个文档(§3.4)
159
+ if (text[i] !== '\\' || text[i + 1] !== 'u') fail('E_WIRE_LONE_SURROGATE', '高位代理后没有跟低位代理');
160
+ i += 2;
161
+ const lo = hex4();
162
+ if (!(lo >= 0xdc00 && lo <= 0xdfff)) fail('E_WIRE_LONE_SURROGATE', '高位代理后不是低位代理');
163
+ out += String.fromCharCode(u, lo);
164
+ } else if (u >= 0xdc00 && u <= 0xdfff) {
165
+ fail('E_WIRE_LONE_SURROGATE', '出现孤立的低位代理');
166
+ } else {
167
+ out += String.fromCharCode(u);
168
+ }
169
+ break;
170
+ }
171
+ default: fail('E_WIRE_PARSE', `非法转义 \\${e ?? '<EOF>'}`);
172
+ }
173
+ }
174
+ checkNoControl(out, kind);
175
+ return out;
176
+ }
177
+
178
+ function num() {
179
+ const start = i;
180
+ while (i < n && /[-+0-9.eExX]/.test(text[i])) i++;
181
+ const tok = text.slice(start, i);
182
+ if (!/^(0|[1-9][0-9]*)$/.test(tok)) {
183
+ fail('E_WIRE_NUMBER', `只允许 [0, 2^53-1] 的非负整数字面量,得到 ${tok}`);
184
+ }
185
+ const v = Number(tok);
186
+ if (!Number.isSafeInteger(v)) fail('E_WIRE_NUMBER', `数字超出 2^53-1:${tok}`);
187
+ return v;
188
+ }
189
+
190
+ function value(depth) {
191
+ if (depth > 64) fail('E_WIRE_DEPTH', 'JSON 嵌套过深');
192
+ ws();
193
+ if (i >= n) fail('E_WIRE_PARSE', '文档意外结束');
194
+ const ch = text[i];
195
+ if (ch === '{') {
196
+ i++;
197
+ // 🔴 `Object.create(null)`:用字面量 `{}` 时,`obj["__proto__"] = v` 走的是
198
+ // setter,**不会**产生自有属性 —— 于是 `Object.keys()` 看不到它,
199
+ // `additionalProperties: false` 就被绕过了,同时还污染了原型。
200
+ // Codex 实测:`parseWireText('{"__proto__":{"pwn":1}}')` 曾能通过
201
+ // `assertExactKeys(o, {required: [], optional: []})`。
202
+ const obj = Object.create(null);
203
+ const seen = new Set();
204
+ ws();
205
+ if (text[i] === '}') { i++; return obj; }
206
+ for (;;) {
207
+ ws();
208
+ const k = str('key');
209
+ // 🔴 按**解码后**的 key 判重复 —— 这正是上游 parseStrict 漏掉的那一步
210
+ if (seen.has(k)) fail('E_WIRE_DUP_KEY', `重复 key ${JSON.stringify(k)}`);
211
+ seen.add(k);
212
+ ws();
213
+ if (text[i] !== ':') fail('E_WIRE_PARSE', '期待 :');
214
+ i++;
215
+ obj[k] = value(depth + 1);
216
+ ws();
217
+ if (text[i] === ',') { i++; continue; }
218
+ if (text[i] === '}') { i++; return obj; }
219
+ fail('E_WIRE_PARSE', '期待 , 或 }');
220
+ }
221
+ }
222
+ if (ch === '[') {
223
+ i++;
224
+ const arr = [];
225
+ ws();
226
+ if (text[i] === ']') { i++; return arr; }
227
+ for (;;) {
228
+ arr.push(value(depth + 1));
229
+ ws();
230
+ if (text[i] === ',') { i++; continue; }
231
+ if (text[i] === ']') { i++; return arr; }
232
+ fail('E_WIRE_PARSE', '期待 , 或 ]');
233
+ }
234
+ }
235
+ if (ch === '"') return str('string');
236
+ if (text.startsWith('true', i)) { i += 4; return true; }
237
+ if (text.startsWith('false', i)) { i += 5; return false; }
238
+ if (text.startsWith('null', i)) { i += 4; return null; }
239
+ if (ch === '-' || (ch >= '0' && ch <= '9')) return num();
240
+ fail('E_WIRE_PARSE', `意外字符 ${JSON.stringify(ch)}`);
241
+ }
242
+
243
+ const out = value(0);
244
+ ws();
245
+ if (i !== n) fail('E_WIRE_TRAILING', '文档末尾有多余内容');
246
+ return out;
247
+ }
248
+
249
+ /** 大小上限 → 严格 UTF-8 → 严格解析。🔴 先查大小再解码,别为 500 MB 的「JSON」分配内存。 */
250
+ export function parseWireJson(bytes, where = 'document') {
251
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
252
+ if (buf.length > MAX_JSON_BYTES) {
253
+ throw new WireError('E_WIRE_TOO_LARGE', `${where} 为 ${buf.length} 字节,超过 ${MAX_JSON_BYTES}`);
254
+ }
255
+ // 🔴 按**原始字节**判 BOM:TextDecoder('utf-8') 默认会把 BOM 吃掉,
256
+ // 解码后再判 U+FEFF 是判不到的。
257
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
258
+ throw new WireError('E_WIRE_BOM', `${where} 含 UTF-8 BOM`);
259
+ }
260
+ let text;
261
+ try {
262
+ text = utf8Strict.decode(buf);
263
+ } catch {
264
+ throw new WireError('E_WIRE_UTF8', `${where} 不是有效 UTF-8`);
265
+ }
266
+ if (text.charCodeAt(0) === 0xfeff) throw new WireError('E_WIRE_BOM', `${where} 含 BOM`);
267
+ const doc = parseWireText(text, where);
268
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
269
+ throw new WireError('E_WIRE_NOT_OBJECT', `${where} 顶层必须是对象`);
270
+ }
271
+ return doc;
272
+ }
273
+
274
+ /**
275
+ * 🔴 canonical 往返:需要逐字节复现的对象(snapshot / timestamp,§3)
276
+ * 重新 canonical 序列化后必须与原字节完全相同。
277
+ *
278
+ * 这一条比任何字段级检查都强:它同时挡掉 key 顺序被打乱、缩进被改、
279
+ * 非 ASCII 未转义、多余空白等一切「语义相同但字节不同」的变体 ——
280
+ * 而字节正是签名与 sha256 所绑定的东西。
281
+ */
282
+ export function assertCanonicalBytes(bytes, doc, where) {
283
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
284
+ const re = Buffer.from(stringify(doc), 'utf8');
285
+ if (!buf.equals(re)) {
286
+ throw new WireError('E_NOT_CANONICAL', `${where} 不是 canonical 形式(11-wire-contract.md §3)`);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 🔴 `additionalProperties: false` + 必填齐全(§2)。
292
+ * 「宁可让旧 CLI 拒绝新字段,也不要让它忽略一个它不理解的安全相关字段」。
293
+ */
294
+ export function assertExactKeys(obj, { required = [], optional = [] }, where) {
295
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
296
+ throw new WireError('E_WIRE_TYPE', `${where} 必须是对象`);
297
+ }
298
+ const allowed = new Set([...required, ...optional]);
299
+ for (const k of Object.keys(obj)) {
300
+ if (!allowed.has(k)) throw new WireError('E_WIRE_UNKNOWN_FIELD', `${where} 出现未知字段 ${k}`);
301
+ }
302
+ for (const k of required) {
303
+ if (!Object.hasOwn(obj, k)) throw new WireError('E_WIRE_MISSING_FIELD', `${where} 缺少必填字段 ${k}`);
304
+ }
305
+ return obj;
306
+ }
307
+
308
+ export function assertUint(v, where) {
309
+ if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) {
310
+ throw new WireError('E_WIRE_NUMBER', `${where} 必须是 [0, 2^53-1] 的非负整数`);
311
+ }
312
+ return v;
313
+ }
314
+
315
+ export function assertString(v, where) {
316
+ if (typeof v !== 'string') throw new WireError('E_WIRE_TYPE', `${where} 必须是字符串`);
317
+ return v;
318
+ }
319
+
320
+ export function assertStringArray(v, where) {
321
+ if (!Array.isArray(v)) throw new WireError('E_WIRE_TYPE', `${where} 必须是数组`);
322
+ v.forEach((x, k) => assertString(x, `${where}[${k}]`));
323
+ return v;
324
+ }
325
+
326
+ /**
327
+ * 严格时间:`YYYY-MM-DDTHH:MM:SSZ`,且必须能**往返** ——
328
+ * `2026-02-30T00:00:00Z` 形状合法但不是真日期,正则挡不住。
329
+ */
330
+ export function parseWireTime(s, where) {
331
+ assertString(s, where);
332
+ if (!RE_TIME.test(s)) throw new WireError('E_WIRE_TIME', `${where} 必须是 YYYY-MM-DDTHH:MM:SSZ,得到 ${s}`);
333
+ const ms = Date.parse(s);
334
+ if (!Number.isFinite(ms)) throw new WireError('E_WIRE_TIME', `${where} 不是有效时间:${s}`);
335
+ const back = new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
336
+ if (back !== s) throw new WireError('E_WIRE_TIME', `${where} 不是真实日期:${s}`);
337
+ return Math.floor(ms / 1000);
338
+ }
339
+
340
+ export function assertAssetDigest(v, where) {
341
+ assertString(v, where);
342
+ if (!RE_ASSET_SHA256.test(v)) throw new WireError('E_WIRE_DIGEST', `${where} 必须形如 sha256:<64 小写 hex>,得到 ${v}`);
343
+ return v;
344
+ }
345
+
346
+ /**
347
+ * 🔴 树摘要必须带算法标识;**遇到不认识的算法前缀 → 拒绝,不降级**
348
+ * (01-artifacts.md §6.1 末段)。
349
+ */
350
+ export function assertTreeDigest(v, where) {
351
+ assertString(v, where);
352
+ if (RE_TREE_DIGEST.test(v)) return v;
353
+ const m = RE_ANY_DIGEST.exec(v);
354
+ if (m) {
355
+ throw new IntegrityError('E_UNKNOWN_DIGEST_ALGO', `${where} 用了不认识的摘要算法 ${m[1]}:${m[2]},拒绝安装(不降级)`);
356
+ }
357
+ throw new WireError('E_WIRE_DIGEST', `${where} 必须形如 geoly-tree-v1:sha256:<64 hex>,得到 ${v}`);
358
+ }
359
+
360
+ export function sha256Of(bytes) {
361
+ return 'sha256:' + createHash('sha256').update(bytes).digest('hex');
362
+ }
363
+
364
+ // ── trust floor ─────────────────────────────────────────────────────────────
365
+
366
+ export const TRUST_SCHEMA = 'geoly.skills.trust/1';
367
+ export const TRUST_FILE = 'trust.json';
368
+ export const METADATA_LOCK = 'metadata.lock.db';
369
+
370
+ const TRUST_KEYS = {
371
+ required: ['schema', 'timestamp_version', 'timestamp_sha256', 'latest_snapshot', 'snapshot_sha256', 'last_verified_at'],
372
+ };
373
+
374
+ export function validateTrustFloor(doc) {
375
+ assertExactKeys(doc, TRUST_KEYS, 'trust.json');
376
+ if (doc.schema !== TRUST_SCHEMA) {
377
+ // §4:主版本不同 → 拒绝,不做「尽力而为地解析」
378
+ throw new WireError('E_SCHEMA', `trust.json 的 schema 必须是 ${TRUST_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
379
+ }
380
+ assertUint(doc.timestamp_version, 'trust.timestamp_version');
381
+ assertUint(doc.latest_snapshot, 'trust.latest_snapshot');
382
+ assertAssetDigest(doc.timestamp_sha256, 'trust.timestamp_sha256');
383
+ assertAssetDigest(doc.snapshot_sha256, 'trust.snapshot_sha256');
384
+ parseWireTime(doc.last_verified_at, 'trust.last_verified_at');
385
+ return doc;
386
+ }
387
+
388
+ /**
389
+ * 🔴 固定 state 目录:先 realpath,再拒绝其下的 symlink。
390
+ *
391
+ * 不做这一步的后果:`trust.json` 或 `metadata.lock.db` 被换成指向别处的软链时,
392
+ * 两个进程会**锁住不同的对象**,排他锁形同虚设。
393
+ * (对手 E 拥有完全控制时本地任何检查都不可信 —— 07-threat-model.md §5 已承认;
394
+ * 这里挡的是「同权限进程顺手做的替换」,不是完全控制。)
395
+ */
396
+ export function resolveStateDir(stateDir) {
397
+ if (!isAbsolute(stateDir)) throw new WireError('E_STATE_DIR', `stateDir 需要绝对路径:${stateDir}`);
398
+ const real = realpathSync(stateDir);
399
+ for (const f of [TRUST_FILE, METADATA_LOCK]) assertNoSymlinkInChain(real, f);
400
+ return real;
401
+ }
402
+
403
+ /**
404
+ * 读 floor。文件不存在 → `null`(bootstrap,残余风险见 07-threat-model.md 6c)。
405
+ * 🔴 文件存在但非法 → **抛错停机**,绝不当成 null。
406
+ * 「读到非法内容则 fail-closed,绝不因为上次报错了就重置」——11-wire-contract.md §5。
407
+ */
408
+ export function readTrustFloor(stateDir) {
409
+ const dir = resolveStateDir(stateDir);
410
+ const path = join(dir, TRUST_FILE);
411
+ let st;
412
+ try {
413
+ st = statSync(path);
414
+ } catch (e) {
415
+ if (e.code === 'ENOENT') return null;
416
+ throw e;
417
+ }
418
+ if (!st.isFile()) throw new WireError('E_TRUST_NOT_FILE', `${path} 不是普通文件`);
419
+ const bytes = readFileSync(path);
420
+ const doc = validateTrustFloor(parseWireJson(bytes, 'trust.json'));
421
+ // 自己写出去的东西必须是 canonical;不是就说明被人动过或版本不匹配 → 停机
422
+ assertCanonicalBytes(bytes, doc, 'trust.json');
423
+ return doc;
424
+ }
425
+
426
+ /**
427
+ * 🔴 抗回滚比较核心(02-registry.md §6 第 3 步 + snapshot 单调性)。
428
+ *
429
+ * 纯函数,无 IO —— 让并发测试与单元测试都能直打这段逻辑。
430
+ * 两个调用点共用它,避免「网络那条路」与「落盘那条路」分叉:
431
+ * - `checkAntiReplay`:候选 vs **启动时读到的** floor,用于接受/拒绝;
432
+ * - `advanceTrustFloor`:候选 vs **锁内重读的** floor,用于提交。
433
+ * 两处的差别只在「磁盘更新」怎么处理(拒绝 vs 重做),由 `onDiskNewer` 区分。
434
+ *
435
+ * @param {object|null} floor 磁盘上的 floor
436
+ * @param {{timestamp_version:number,timestamp_sha256:string,latest_snapshot:number,snapshot_sha256:string}} cand
437
+ * @param {'reject'|'redo'} onDiskNewer
438
+ */
439
+ export function compareFloor(floor, cand, onDiskNewer = 'reject') {
440
+ if (floor === null) return { action: 'write', reason: 'bootstrap' };
441
+
442
+ // ① version 更低
443
+ if (cand.timestamp_version < floor.timestamp_version) {
444
+ if (onDiskNewer === 'redo') {
445
+ // 🔴 不是「沿用磁盘值继续」,而是要求调用方**从磁盘 floor 重做完整绑定比较**。
446
+ // 沿用自己已验的旧 timestamp 继续下载 → floor 虽未回退,本进程仍按旧快照装东西。
447
+ return { action: 'redo', reason: 'disk-newer', diskFloor: floor };
448
+ }
449
+ throw new IntegrityError(
450
+ 'E_ROLLBACK',
451
+ `timestamp.version=${cand.timestamp_version} 低于本地 floor ${floor.timestamp_version}:回滚攻击`,
452
+ );
453
+ }
454
+
455
+ // ② version 相同 → 🔴 三元组必须完全一致(v6 漏了 timestamp_sha256)
456
+ if (cand.timestamp_version === floor.timestamp_version) {
457
+ const diffs = [];
458
+ if (cand.latest_snapshot !== floor.latest_snapshot) diffs.push('latest_snapshot');
459
+ if (cand.snapshot_sha256 !== floor.snapshot_sha256) diffs.push('snapshot_sha256');
460
+ if (cand.timestamp_sha256 !== floor.timestamp_sha256) diffs.push('timestamp_sha256');
461
+ if (diffs.length) {
462
+ throw new IntegrityError(
463
+ 'E_FLOOR_MISMATCH',
464
+ `同一 timestamp.version=${cand.timestamp_version} 却有不同的 ${diffs.join(' / ')}:完整性事件`,
465
+ );
466
+ }
467
+ return { action: 'unchanged', reason: 'same-version' };
468
+ }
469
+
470
+ // ③ version 更高 → 还要过 snapshot 单调性
471
+ if (cand.latest_snapshot < floor.latest_snapshot) {
472
+ throw new IntegrityError(
473
+ 'E_SNAPSHOT_ROLLBACK',
474
+ `timestamp 更新(${floor.timestamp_version} → ${cand.timestamp_version})却把 latest_snapshot ` +
475
+ `从 ${floor.latest_snapshot} 退回 ${cand.latest_snapshot}:旧 yank 状态会重新生效`,
476
+ );
477
+ }
478
+ if (cand.latest_snapshot === floor.latest_snapshot && cand.snapshot_sha256 !== floor.snapshot_sha256) {
479
+ throw new IntegrityError(
480
+ 'E_SNAPSHOT_SWAP',
481
+ `latest_snapshot=${cand.latest_snapshot} 未变,snapshot_sha256 却变了:完整性事件`,
482
+ );
483
+ }
484
+ return { action: 'write', reason: 'advance' };
485
+ }
486
+
487
+ /** 候选 timestamp vs 本地 floor(§6 第 3 步)。通过则返回,否则抛 IntegrityError。 */
488
+ export function checkAntiReplay(floor, candidate) {
489
+ return compareFloor(floor, candidate, 'reject');
490
+ }
491
+
492
+ export function makeFloor({ timestamp_version, timestamp_sha256, latest_snapshot, snapshot_sha256, now = new Date() }) {
493
+ return validateTrustFloor({
494
+ schema: TRUST_SCHEMA,
495
+ timestamp_version,
496
+ timestamp_sha256,
497
+ latest_snapshot,
498
+ snapshot_sha256,
499
+ last_verified_at: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'),
500
+ });
501
+ }
502
+
503
+ /**
504
+ * 🔴 在 metadata 排他锁下原子推进 floor(02-registry.md §5)。
505
+ *
506
+ * 「写前重读」本身不是 CAS —— 没有真锁时 P1、P2 都重读 floor=10,P2 写 12、
507
+ * P1 写 11,仍会回退。所以**真锁是必需的,重读只是额外的防御层**,两者都要。
508
+ *
509
+ * 🔴 临界区内不得 COMMIT(COMMIT 会释放 SQLite 的排他锁);写盘走 writeAtomic,
510
+ * 释放锁在 finally 里做。
511
+ *
512
+ * 返回 `{action}`:
513
+ * - `written` 已推进
514
+ * - `unchanged` 同版本且三元组一致,无需写
515
+ * - `redo` 磁盘 floor 更新,🔴 调用方必须用 `result.diskFloor` 重做 §6 第 3–5 步,
516
+ * **不得**沿用自己已验的旧 timestamp/旧 snapshot 继续下载
517
+ */
518
+ export function advanceTrustFloor(stateDir, candidate, { cli = 'skills-hub' } = {}) {
519
+ validateTrustFloor(candidate);
520
+ const dir = resolveStateDir(stateDir);
521
+ const release = acquire(join(dir, METADATA_LOCK), { cli });
522
+ try {
523
+ const disk = readTrustFloor(dir); // 🔴 锁内重读磁盘,不用内存里那份
524
+ const verdict = compareFloor(disk, candidate, 'redo');
525
+ if (verdict.action === 'write') {
526
+ writeAtomic(join(dir, TRUST_FILE), stringify(candidate));
527
+ return { action: 'written', reason: verdict.reason, floor: candidate };
528
+ }
529
+ if (verdict.action === 'unchanged') return { action: 'unchanged', reason: verdict.reason, floor: disk };
530
+ return { action: 'redo', reason: verdict.reason, diskFloor: verdict.diskFloor };
531
+ } finally {
532
+ release();
533
+ }
534
+ }
535
+
536
+ /**
537
+ * 🔴 提交前的再核对(Codex 评审提出的第二个竞态)。
538
+ *
539
+ * `advanceTrustFloor` 释放锁之后,另一个进程可以立刻把 floor 推得更高;
540
+ * 本进程若继续按旧 snapshot 下载安装,就等于「新 yank 没能阻止并发中的旧安装」。
541
+ * 规格没有明确这一点(见交付汇报的「规格缺口」一节),
542
+ * 这里提供判据,由安装事务在**提交前**调用:不一致就放弃,而不是装完再说。
543
+ *
544
+ * ⚠️ **它本身不是提交屏障**:本函数不持锁,检查完到真正提交之间 floor 仍可能被推进。
545
+ * 要让它成为屏障,安装事务必须在**自己持有 metadata 锁的那段临界区内**调用它,
546
+ * 并在同一段临界区里完成提交。单独调用只是把窗口从「整个安装过程」收窄到
547
+ * 「检查到提交之间」,不是消除。
548
+ */
549
+ export function assertFloorUnchanged(stateDir, expected) {
550
+ const disk = readTrustFloor(stateDir);
551
+ if (disk === null) {
552
+ throw new IntegrityError('E_FLOOR_VANISHED', 'trust floor 在本次安装期间消失了');
553
+ }
554
+ for (const k of ['timestamp_version', 'timestamp_sha256', 'latest_snapshot', 'snapshot_sha256']) {
555
+ if (disk[k] !== expected[k]) {
556
+ throw new IntegrityError(
557
+ 'E_FLOOR_MOVED',
558
+ `安装期间 trust floor 的 ${k} 变了(${expected[k]} → ${disk[k]}):放弃本次安装并重跑`,
559
+ );
560
+ }
561
+ }
562
+ return disk;
563
+ }