@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,732 @@
1
+ // install —— §5.2 的第 5–10 步、§5.3 的逐项段模型、§5.6 的三阶段清理,
2
+ // 外加 attic 归档用的 canonical ustar 写入与重验。
3
+ //
4
+ // 规格:04-install.md §5.2 / §5.2.1 / §5.3 / §5.4(幂等五分支)/ §5.6 / §5.7、
5
+ // 01-artifacts.md §4/§5/§6、ERRATA E-5(尾部恰好两个零块)、E-6(不 shell out)。
6
+ //
7
+ // 🔴 本模块**不 import recover.mjs**。段函数(`applyItems` / `verifyAndCommit` /
8
+ // `runCleanup` / `idempotentRenameDir`)在这里导出,由 recover 复用 ——
9
+ // 「正向首跑与 --continue 共用同一段代码」是 §5.4 幂等前向恢复的前提。
10
+
11
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { chmodSync } from 'node:fs';
13
+ import { join, relative, sep } from 'node:path';
14
+ import { stringify } from './canonical-json.mjs';
15
+ import { treeDigest } from './tree-digest.mjs';
16
+ import {
17
+ writeAtomic, mkdirChainFsync, renameDirFsync, rmtreeFsync, fsyncDir, sameDevice,
18
+ } from './atomic-fs.mjs';
19
+ import { fp } from './fault-inject.mjs';
20
+ import { parseTar, canonicalUstarSplit, assertArtifactPath } from './untar.mjs';
21
+ import { writeEntries } from './artifact.mjs';
22
+ import {
23
+ Corrupt, bad, readJournal, writeJournal, sweepTmp, JOURNAL_SCHEMA,
24
+ } from './journal.mjs';
25
+ import {
26
+ layout, readLedger, writeLedger, applyImageSide, isGenerationFrozen,
27
+ } from './ledger.mjs';
28
+ import { buildManifest, validateManifest, strictlyMatches, REVERSE_OP } from './plan.mjs';
29
+ import { readJsonStrict } from './journal.mjs';
30
+ import { assertFloorUnchanged } from './trust.mjs';
31
+
32
+ export { Corrupt };
33
+
34
+ const BLOCK = 512;
35
+
36
+ // ════════════════════════════════════════════════════════════════════════════
37
+ // canonical ustar 写入(attic 的 `<name>.tar`)
38
+ // ════════════════════════════════════════════════════════════════════════════
39
+
40
+ function octal(n, width) {
41
+ const s = n.toString(8);
42
+ if (s.length > width - 1) bad(`tar:数值 ${n} 放不进 ${width} 字节的八进制域`);
43
+ return Buffer.from(s.padStart(width - 1, '0') + '\0', 'latin1');
44
+ }
45
+
46
+ /**
47
+ * 🔴 E-5/E-6:**自己写字节,不 shell out 到系统 tar**。
48
+ * macOS 的 `tar` 会注入 AppleDouble(`._*`)成员携带 xattr,而 `tar -tvf` 看不见它 ——
49
+ * 打出来的包会被我们自己的校验器拒掉,打包的人却只会觉得校验器有 bug。
50
+ *
51
+ * 形状与 src/untar.mjs 的 `parseTar` 逐条对齐:ustar 普通文件条目、
52
+ * uid/gid/mtime/dev 全 0、uname/gname 空、mode ∈ {0644,0755}、
53
+ * 路径按 canonical ustar 切分、按 path 字节序严格升序、**尾部恰好两个零块**。
54
+ */
55
+ export function writeCanonicalTar(entries) {
56
+ const sorted = [...entries].sort((a, b) =>
57
+ Buffer.compare(Buffer.from(a.path, 'utf8'), Buffer.from(b.path, 'utf8')));
58
+ const chunks = [];
59
+ let prev = null;
60
+ for (const e of sorted) {
61
+ if (prev !== null && prev === e.path) bad(`tar:重复路径 ${e.path}`);
62
+ prev = e.path;
63
+ // 🔴 写入端必须走**和读取端同一条**路径校验,否则我们能写出自己读不回来的
64
+ // 归档:`writeCanonicalTar([{path:'../x'}])` 原本会成功,而 parseTar 报
65
+ // E_PATH_DOTDOT。这与 devmajor 那次是同一类错误 —— 写入端与读取端各自
66
+ // 合理但接受集合不同。判据:**writer 接受的每一个输入,parser 都必须接受。**
67
+ assertArtifactPath(e.path, `tar:${e.path}`);
68
+ const split = canonicalUstarSplit(e.path);
69
+ if (split === null) bad(`tar:路径无法被 ustar 切分:${e.path}`);
70
+ if (e.mode !== 0o644 && e.mode !== 0o755) bad(`tar:mode 只允许 0644/0755,得到 0${e.mode.toString(8)}`);
71
+ const h = Buffer.alloc(BLOCK);
72
+ Buffer.from(split.name, 'utf8').copy(h, 0);
73
+ octal(e.mode, 8).copy(h, 100);
74
+ octal(0, 8).copy(h, 108); // uid
75
+ octal(0, 8).copy(h, 116); // gid
76
+ octal(e.data.length, 12).copy(h, 124); // size
77
+ octal(0, 12).copy(h, 136); // mtime
78
+ h.fill(0x20, 148, 156); // chksum 先填空格
79
+ h[156] = 0x30; // typeflag '0'
80
+ Buffer.from('ustar\0', 'latin1').copy(h, 257);
81
+ Buffer.from('00', 'latin1').copy(h, 263);
82
+ // 🔴 devmajor/devminor 必须显式写成 7 位八进制,不能留成 Buffer.alloc 的全 NUL。
83
+ // 两种写法都表示 0,但 canonical 编码只能有一种 —— 摘要绑的是字节(ERRATA E-3)。
84
+ // 合并时才发现:写入端留空 NUL、读取端(加固后)要求八进制,
85
+ // 两边各自都说得通,一合就 15 个测试全红。往返测试见 test/install.test.mjs。
86
+ octal(0, 8).copy(h, 329); // devmajor
87
+ octal(0, 8).copy(h, 337); // devminor
88
+ Buffer.from(split.prefix, 'utf8').copy(h, 345);
89
+ let sum = 0;
90
+ for (let i = 0; i < BLOCK; i++) sum += h[i];
91
+ // POSIX:6 位八进制 + NUL + 空格
92
+ Buffer.from(sum.toString(8).padStart(6, '0') + '\0 ', 'latin1').copy(h, 148);
93
+ chunks.push(h, e.data);
94
+ const pad = (BLOCK - (e.data.length % BLOCK)) % BLOCK;
95
+ if (pad) chunks.push(Buffer.alloc(pad));
96
+ }
97
+ chunks.push(Buffer.alloc(2 * BLOCK)); // 🔴 恰好两个零块,不多不少
98
+ return Buffer.concat(chunks);
99
+ }
100
+
101
+ /** 把一棵树读成 tar 条目(无跟随;类型/mode 由 treeDigest 的同一套规则把关) */
102
+ export function treeToEntries(root) {
103
+ const out = [];
104
+ (function rec(dir) {
105
+ for (const name of readdirSync(dir).sort()) {
106
+ const abs = join(dir, name);
107
+ const st = lstatSync(abs);
108
+ if (st.isSymbolicLink()) bad(`归档:拒绝 symlink ${abs}`);
109
+ if (st.isDirectory()) { rec(abs); continue; }
110
+ if (!st.isFile()) bad(`归档:拒绝非普通文件 ${abs}`);
111
+ if (st.nlink !== 1) bad(`归档:拒绝 hardlink ${abs}`);
112
+ const mode = st.mode & 0o777;
113
+ out.push({ path: relative(root, abs).split(sep).join('/'), mode, data: readFileSync(abs) });
114
+ }
115
+ })(root);
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * 🔴 从磁盘**重算**归档的树摘要 —— 不信任何自称的字段。
121
+ * 解到一个隔离目录再算,复用 `treeDigest` 这**唯一一份**实现
122
+ *(自己再写一份 leaf 编码 = 两份实现可以互相不同意,而其中一份错了没人发现)。
123
+ */
124
+ export function archiveDigest(tarPath, scratchParent) {
125
+ const bytes = readFileSync(tarPath);
126
+ const { entries } = parseTar(bytes);
127
+ const dir = join(scratchParent, `.verify-${process.pid}-${Math.random().toString(36).slice(2)}`);
128
+ mkdirChainFsync(dir);
129
+ try {
130
+ writeEntries(dir, entries);
131
+ return treeDigest(dir);
132
+ } finally {
133
+ rmtreeFsync(dir);
134
+ }
135
+ }
136
+
137
+ /** 完整重验一份 attic 归档;返回重算出来的摘要。`expect` 给了就必须相等。 */
138
+ export function verifyArchive(tarPath, expect, scratchParent) {
139
+ if (!existsSync(tarPath)) bad(`attic 归档不存在:${tarPath}`);
140
+ const real = archiveDigest(tarPath, scratchParent);
141
+ if (expect !== undefined && real !== expect) {
142
+ bad(`attic 归档摘要 ${real} 与期望 ${expect} 不符:${tarPath}`);
143
+ }
144
+ return real;
145
+ }
146
+
147
+ /** 把归档还原到 destDir 并重验(rollback 的 `as-*-cleaned` 用) */
148
+ export function restoreArchive(tarPath, destDir, expect) {
149
+ const { entries } = parseTar(readFileSync(tarPath));
150
+ if (existsSync(destDir)) rmtreeFsync(destDir);
151
+ mkdirChainFsync(destDir);
152
+ writeEntries(destDir, entries);
153
+ const got = treeDigest(destDir);
154
+ if (got !== expect) bad(`归档还原后摘要 ${got} 与期望 ${expect} 不符:${tarPath}`);
155
+ return got;
156
+ }
157
+
158
+ // ════════════════════════════════════════════════════════════════════════════
159
+ // §5.4 的幂等 rename(五分支,规范强制)
160
+ // ════════════════════════════════════════════════════════════════════════════
161
+
162
+ /**
163
+ * 🔴 **两端都要验**(v10 只验 `Y`):
164
+ * ① `Y` 存在且摘要 == 期望 **且 `X` 缺席** → 已完成,跳过;
165
+ * ② `Y` 存在且摘要符,**但 `X` 也存在** → 外部重建/替换过源 → **停机 corrupt**;
166
+ * ③ `Y` 缺席、`X` 存在且摘要 == 期望 → 执行;
167
+ * ④ `Y` 缺席、`X` 存在但摘要不符 → **停机 corrupt**;
168
+ * ⑤ 两者都不存在 → **停机 corrupt**。
169
+ */
170
+ export function idempotentRenameDir(from, to, expectDigest) {
171
+ const hasFrom = existsSync(from);
172
+ const hasTo = existsSync(to);
173
+ if (hasTo) {
174
+ let dTo = null;
175
+ try { dTo = treeDigest(to); } catch (e) { bad(`目标 ${to} 无法成像:${e.message}`); }
176
+ if (dTo === expectDigest) {
177
+ if (hasFrom) bad(`分支②:${to} 已正确但 ${from} 也在(外部重建过源)`);
178
+ return 'skipped'; // ①
179
+ }
180
+ bad(`${to} 存在但摘要 ${dTo} != 期望 ${expectDigest}`);
181
+ }
182
+ if (!hasFrom) bad(`分支⑤:${from} 与 ${to} 都不存在`);
183
+ let dFrom = null;
184
+ try { dFrom = treeDigest(from); } catch (e) { bad(`源 ${from} 无法成像:${e.message}`); }
185
+ if (dFrom !== expectDigest) bad(`分支④:${from} 摘要 ${dFrom} != 期望 ${expectDigest}`);
186
+ renameDirFsync(from, to); // ③
187
+ return 'done';
188
+ }
189
+
190
+ // ════════════════════════════════════════════════════════════════════════════
191
+ // 第 5 步:建 tx 目录链、把已验证的树放入 stage
192
+ // ════════════════════════════════════════════════════════════════════════════
193
+
194
+ function copyTreeFsync(src, dest) {
195
+ mkdirChainFsync(dest);
196
+ for (const name of readdirSync(src).sort()) {
197
+ const abs = join(src, name);
198
+ const st = lstatSync(abs);
199
+ if (st.isSymbolicLink()) bad(`stage:拒绝 symlink ${abs}`);
200
+ if (st.isDirectory()) { copyTreeFsync(abs, join(dest, name)); continue; }
201
+ if (!st.isFile()) bad(`stage:拒绝非普通文件 ${abs}`);
202
+ const to = join(dest, name);
203
+ writeAtomic(to, readFileSync(abs));
204
+ const mode = st.mode & 0o777;
205
+ // writeAtomic 固定用 0644 建 tmp;0755 要补一次 chmod。
206
+ // 🔴 这不是「信 chmod 成功」—— 第 5 步末尾会重算整棵树的摘要,mode 进摘要,
207
+ // chmod 没生效会在那里被抓住。
208
+ if (mode !== 0o644) chmodSync(to, mode);
209
+ }
210
+ fsyncDir(dest);
211
+ }
212
+
213
+ /** 第 5 步。跨设备则复制 + fsync;同设备直接 rename 进来。 */
214
+ export function stageTrees(P, plan) {
215
+ mkdirChainFsync(P.stage);
216
+ mkdirChainFsync(P.retired);
217
+ for (const [name, it] of Object.entries(plan.items)) {
218
+ if (it.op === 'retire-only') continue;
219
+ const dest = join(P.stage, name);
220
+ if (existsSync(dest)) {
221
+ // 幂等:已经 stage 过且摘要符就跳过(`.tmp` 一律先扫掉)
222
+ if (treeDigest(dest) === it.new_digest) continue;
223
+ rmtreeFsync(dest);
224
+ }
225
+ const src = plan.sources?.[name];
226
+ if (!src || !existsSync(src)) bad(`第 5 步:${name} 没有可 stage 的源目录`);
227
+ if (sameDevice(src, P.stage)) renameDirFsync(src, dest);
228
+ else copyTreeFsync(src, dest);
229
+ // 🔴 **重算树摘要** —— 不信调用方给的值
230
+ const got = treeDigest(dest);
231
+ if (got !== it.new_digest) bad(`第 5 步:${name} stage 后摘要 ${got} != 计划的 ${it.new_digest}`);
232
+ }
233
+ }
234
+
235
+ // ════════════════════════════════════════════════════════════════════════════
236
+ // 第 6 步:提交点
237
+ // ════════════════════════════════════════════════════════════════════════════
238
+
239
+ /**
240
+ * 🔴 **提交点之前的最后一刻**:复验 trust floor(抗回滚在「解析」与「安装」之间的接缝)。
241
+ *
242
+ * 背景:`snapshot.resolveCurrent()` 推进 floor 之后就**释放了 metadata 锁**,之后才下载与安装。
243
+ * 并发的另一个进程若在这个空档里把 floor 又推进一次,本进程仍会按**旧快照**装完。
244
+ *
245
+ * 🔴 **这是一次检查,不是提交屏障 —— 必须如实说清楚。**
246
+ * 真屏障要在同一个临界区里持有 metadata 锁直到写入生效点,而那做不到:
247
+ * · §5.1 的全序是 `metadata → repo → target`,安装事务此刻已经持着 target 锁,
248
+ * 再去取 metadata 锁就是**反序**,正是规范明令禁止的(会出现「双方各持一半」);
249
+ * · `src/lock.mjs` 还**禁止重入**,同一进程不能对同一路径再 acquire 一次。
250
+ * 因此闭合它需要改 §5.1 的加锁全序(或把 floor 的权威搬进 target 锁),属于规范级改动。
251
+ *
252
+ * 放在这里的理由:**提交点之前是最后一个「放弃是免费的」时刻**(target 未被改动,
253
+ * tx 目录可直接丢弃)。过了提交点,事务已经被承诺,恢复只能续做或回滚,
254
+ * 不得再拿「现在的 floor」去重新评估当初的选择 —— 否则一次并发推进就会把一个
255
+ * 已提交的事务永久卡死。§5.8 的「`--from-generation` 豁免当前状态门」是同一条道理。
256
+ *
257
+ * 🔴 `floor` **必须显式给**:`undefined` 直接拒绝。没有 registry 出处的事务
258
+ * (从 attic 复位、从 quarantine 重建)要显式写 `floor: null` —— 让调用方**做决定**,
259
+ * 而不是让「忘了传」静默等于「不检查」。
260
+ */
261
+ export function assertFloorBarrier(opts) {
262
+ if (!('floor' in opts)) {
263
+ bad('提交点前的 trust floor 复验:opts.floor 必须显式给出(无 registry 出处时显式传 null)');
264
+ }
265
+ if (opts.floor === null) return { checked: false, reason: 'no-registry-provenance' };
266
+ const { stateDir, expected } = opts.floor;
267
+ if (!stateDir || !expected) bad('opts.floor 必须是 { stateDir, expected }');
268
+ assertFloorUnchanged(stateDir, expected); // E_FLOOR_MOVED / E_FLOOR_VANISHED
269
+ return { checked: true };
270
+ }
271
+
272
+ /**
273
+ * 🔴 ledger 与 journal **各自原子写**,中间可能只成功一个 —— §5.4.2 的双文件规则
274
+ * 专治那一格。写 ledger 的是「transaction 指针」,不是 post。
275
+ */
276
+ export function commitPoint(P, plan, opts = {}) {
277
+ const { repairId } = opts;
278
+ assertFloorBarrier(opts);
279
+ const J = {
280
+ schema: JOURNAL_SCHEMA,
281
+ generation: plan.generation,
282
+ items: plan.items,
283
+ ledger_image: plan.ledger_image,
284
+ phase: 'prepared',
285
+ tx_dir: plan.tx_dir,
286
+ };
287
+ if (plan.adopt_assertions) J.adopt_assertions = plan.adopt_assertions;
288
+ if (plan.unadopt_assertions) J.unadopt_assertions = plan.unadopt_assertions;
289
+ if (repairId) J.repair_id = repairId;
290
+
291
+ fp('tx:step6:pre-ledger', { gen: plan.generation });
292
+ const L = readLedger(P.ledger);
293
+ writeLedger(P.ledger, { ...L, transaction: { generation: plan.generation, tx_dir: plan.tx_dir } });
294
+ fp('tx:step6:between-ledger-journal', { gen: plan.generation });
295
+ mkdirChainFsync(P.journalDir);
296
+ writeJournal(P.journal, J);
297
+ fp('tx:step6:post-journal', { gen: plan.generation });
298
+ return J;
299
+ }
300
+
301
+ // ════════════════════════════════════════════════════════════════════════════
302
+ // 第 7 步:§5.3 的逐项段(正向首跑与 --continue 共用)
303
+ // ════════════════════════════════════════════════════════════════════════════
304
+
305
+ /**
306
+ * 🔴 段模型:每个已持久化的 `state` 界定一个段,`--continue` = 把该段从头到尾
307
+ * 幂等地重跑一遍。因此本函数**同时**是正向首跑与恢复续做的实现。
308
+ *
309
+ * `swap` : planned →〔② T→R〕→ retired →〔④ S→T〕→ swapped
310
+ * `install-new`: planned →〔④ S→T〕→ swapped
311
+ * `retire-only`: planned →〔② T→R〕→ retired → verified(无新树可验)
312
+ */
313
+ export function applyItems(target, J, P) {
314
+ for (const name of Object.keys(J.items).sort()) {
315
+ const it = J.items[name];
316
+ if (it.state === 'corrupt') bad(`${name} 停在 corrupt,只能 --reinstall 或人工介入`);
317
+ const T = join(target, name);
318
+ const R = join(P.retired, name);
319
+ const S = join(P.stage, name);
320
+
321
+ if (it.had_old && it.state === 'planned') {
322
+ // 🔴 §5.6:§5.3 的 ② 真正 rename **之前再实测一次并比对** —— 第 6 步与第 7 步
323
+ // 之间用户可能改了 target。这一步就是幂等五分支里 planned 段的源端校验,
324
+ // 不是另加一次独立断言。
325
+ fp('tx:item:pre-retire-rename', { name });
326
+ idempotentRenameDir(T, R, it.old_digest);
327
+ fp('tx:item:post-retire-rename', { name });
328
+ it.state = 'retired';
329
+ writeJournal(P.journal, J);
330
+ fp('tx:item:post-state-retired', { name });
331
+ }
332
+
333
+ if (it.op === 'retire-only') {
334
+ if (it.state === 'retired') {
335
+ it.state = 'verified'; // §5.3:无新树可验,从 retired **直接进 verified**
336
+ writeJournal(P.journal, J);
337
+ }
338
+ continue;
339
+ }
340
+
341
+ if (it.state === 'planned' || it.state === 'retired') {
342
+ fp('tx:item:pre-swap-rename', { name });
343
+ idempotentRenameDir(S, T, it.new_digest);
344
+ fp('tx:item:post-swap-rename', { name });
345
+ it.state = 'swapped';
346
+ writeJournal(P.journal, J);
347
+ fp('tx:item:post-state-swapped', { name });
348
+ }
349
+ }
350
+ return J;
351
+ }
352
+
353
+ // ════════════════════════════════════════════════════════════════════════════
354
+ // 第 8 / 9 步
355
+ // ════════════════════════════════════════════════════════════════════════════
356
+
357
+ /**
358
+ * 🔴 §4.2 / §5.10:`adopt` / `unadopt` 是**逻辑项**,写 ledger post **之前**必须
359
+ * 再验一次目标目录仍满足断言。否则外部在初验之后改树,账本会**认领错误内容**。
360
+ * 失败时把 `state` **原子持久化**为 `assertion-corrupt`(它不是物理 corrupt,分流不同)。
361
+ */
362
+ export function reverifyAssertions(target, J, P) {
363
+ const check = (key, name, a, what) => {
364
+ const dir = join(target, name);
365
+ const m = strictlyMatches(dir, a.tree_digest);
366
+ if (m.ok) return true;
367
+ if (a.state !== 'assertion-corrupt') {
368
+ a.state = 'assertion-corrupt';
369
+ writeJournal(P.journal, J);
370
+ }
371
+ bad(`${what} 断言失败(${key}[${name}]):${m.why}`);
372
+ return false;
373
+ };
374
+ for (const [name, a] of Object.entries(J.adopt_assertions ?? {})) {
375
+ if (a.state === 'assertion-corrupt') {
376
+ bad(`adopt[${name}] 处于 assertion-corrupt:--continue 拒绝,出路是 --rollback`);
377
+ }
378
+ check('adopt_assertions', name, a, 'adopt');
379
+ }
380
+ for (const [name, a] of Object.entries(J.unadopt_assertions ?? {})) {
381
+ // unadopt 的 assertion-corrupt 的**唯一自动出路**就是这里:严格复验成功即转回 ok
382
+ const dir = join(target, name);
383
+ const m = strictlyMatches(dir, a.tree_digest);
384
+ if (m.ok) {
385
+ if (a.state !== 'ok') { a.state = 'ok'; writeJournal(P.journal, J); }
386
+ continue;
387
+ }
388
+ if (a.state !== 'assertion-corrupt') {
389
+ a.state = 'assertion-corrupt';
390
+ writeJournal(P.journal, J);
391
+ }
392
+ bad(`unadopt[${name}] 断言失败:${m.why}。`
393
+ + '首选出路:把该目录**严格恢复为断言的摘要**后再跑 recover --continue');
394
+ }
395
+ }
396
+
397
+ export function verifyAndCommit(target, J, P) {
398
+ // ── 第 8 步:对落位目录重算树摘要 ─────────────────────────────────────────
399
+ for (const name of Object.keys(J.items).sort()) {
400
+ const it = J.items[name];
401
+ if (it.state !== 'swapped') continue;
402
+ let got = null;
403
+ try { got = treeDigest(join(target, name)); } catch { got = null; }
404
+ if (got !== it.new_digest) {
405
+ // 🔴 不符 → 该项 state: corrupt,transaction 保持非 null,**retired/ 一律不动**
406
+ it.state = 'corrupt';
407
+ writeJournal(P.journal, J);
408
+ bad(`第 8 步校验不符:${name} 实算 ${got},期望 ${it.new_digest}`);
409
+ }
410
+ it.state = 'verified';
411
+ writeJournal(P.journal, J);
412
+ fp('tx:step8:post-state-verified', { name });
413
+ }
414
+ if (J.phase !== 'prepared') return J;
415
+
416
+ reverifyAssertions(target, J, P);
417
+
418
+ // ── 第 9 步:账本 patch + journal 置 cleanup_pending ──────────────────────
419
+ fp('tx:step9:pre-ledger', {});
420
+ const L = readLedger(P.ledger);
421
+ const next = applyImageSide(L, J.ledger_image.post, { archiveDir: P.auditArchiveDir });
422
+ // transaction 保留至清理结束(🔴 它**不进镜像**,由这里按结果置)
423
+ writeLedger(P.ledger, { ...next, transaction: { generation: J.generation, tx_dir: J.tx_dir } });
424
+ fp('tx:step9:between-ledger-journal', {});
425
+ for (const it of Object.values(J.items)) it.state = 'done';
426
+ J.phase = 'cleanup_pending';
427
+ writeJournal(P.journal, J);
428
+ fp('tx:step9:post-journal', {});
429
+ return J;
430
+ }
431
+
432
+ // ════════════════════════════════════════════════════════════════════════════
433
+ // 第 10 步:§5.6 三阶段清理
434
+ // ════════════════════════════════════════════════════════════════════════════
435
+
436
+ /**
437
+ * 【A 全部 tar 先落地,一棵 retired 都不删】→【B 写整代 manifest】→【C 才允许删】
438
+ *
439
+ * 🔴 三条前提,缺一不可:
440
+ * 1. 崩在 checkpoint 之前,**必须从磁盘重验 A 的摘要,不能只信 journal**;
441
+ * 2. 每一次 `fsync` 失败都必须 fail-closed;
442
+ * 3. 自动清理只在 `journal.phase = cleanup_pending` 之后进入。
443
+ */
444
+ export function runCleanup(target, J, P, opts = {}) {
445
+ if (J.phase !== 'cleanup_pending') bad(`runCleanup:phase 必须是 cleanup_pending,得到 ${J.phase}`);
446
+ const names = Object.keys(J.items).sort();
447
+ const keepGenerations = opts.keepGenerations ?? 3;
448
+ const createdAt = opts.now ?? nowUtc();
449
+
450
+ // ── 阶段 A ────────────────────────────────────────────────────────────────
451
+ for (const name of names) {
452
+ const it = J.items[name];
453
+ if (it.cleanup === 'done') continue;
454
+ const tar = join(P.attic, `${name}.tar`);
455
+ if (it.cleanup === 'tar_durable') {
456
+ // 🔴 前提 1:journal 说 tar_durable 不算数,每次都从磁盘重验
457
+ if (it.had_old) verifyArchive(tar, it.old_digest, P.state);
458
+ continue;
459
+ }
460
+ if (!it.had_old) {
461
+ // install-new 没有旧树,跳过 ①–④ 直接置 tar_durable(manifest 里记 "tar": null)
462
+ it.cleanup = 'tar_durable';
463
+ writeJournal(P.journal, J);
464
+ fp('cleanup:A:post-state-tar-durable', { name });
465
+ continue;
466
+ }
467
+ const R = join(P.retired, name);
468
+ mkdirChainFsync(P.attic);
469
+ if (!existsSync(tar)) {
470
+ sweepTmp(P.attic); // §5.4:`.tmp` 残留一律先删再重写
471
+ const tmp = join(P.attic, `${name}.tar.tmp`);
472
+ writeAtomic(tmp, writeCanonicalTar(treeToEntries(R)));
473
+ fp('cleanup:A:post-tar-tmp', { name });
474
+ // ② 🔴 三方比对:tar 内容(**重算**)== retired/<name>/ == journal 的 old_digest
475
+ const aDigest = archiveDigest(tmp, P.state);
476
+ const rDigest = treeDigest(R);
477
+ if (aDigest !== rDigest || rDigest !== it.old_digest) {
478
+ bad(`阶段 A 三方比对失败:${name}(tar=${aDigest} retired=${rDigest} journal=${it.old_digest})`);
479
+ }
480
+ fp('cleanup:A:post-compare', { name });
481
+ renameDirFsync(tmp, tar);
482
+ fp('cleanup:A:post-tar-rename', { name });
483
+ } else {
484
+ // 🔴 崩在 tar rename 之后、checkpoint 之前:**A 与 R 都要重验**(Codex 第二轮 #2)。
485
+ // 只验 A 会让「外部改写过的 R」在阶段 C 被当成可删对象删掉 ——
486
+ // 而 §5.6 的阶段 A 要求的是**三方**比对,不是两方。
487
+ // 此刻 cleanup 缺席 ⇒ 阶段 C 对该项还没跑过 ⇒ R 必须仍是完整旧树。
488
+ verifyArchive(tar, it.old_digest, P.state);
489
+ const rNow = treeDigest(R);
490
+ if (rNow !== it.old_digest) {
491
+ bad(`阶段 A 续做时 retired/${name} 摘要 ${rNow} != ${it.old_digest}(外部改写过),停机`);
492
+ }
493
+ }
494
+ it.cleanup = 'tar_durable';
495
+ writeJournal(P.journal, J);
496
+ fp('cleanup:A:post-state-tar-durable', { name });
497
+ }
498
+ // 逻辑项不参与 A / C(没有旧树、也没有 retired/)
499
+
500
+ // ── 阶段 B ────────────────────────────────────────────────────────────────
501
+ const mPath = join(P.attic, 'manifest.json');
502
+ if (J.manifest !== 'durable') {
503
+ fp('cleanup:B:pre-manifest', {});
504
+ mkdirChainFsync(P.attic);
505
+ if (existsSync(mPath)) {
506
+ // 幂等:已存在且自洽就沿用(重写会换掉 created_at,破坏 I7 收敛)
507
+ assertManifestUsable(mPath, J, P);
508
+ } else {
509
+ const L = readLedger(P.ledger);
510
+ writeAtomic(mPath, stringify(buildManifest(J, L, target, { createdAt })));
511
+ }
512
+ fp('cleanup:B:post-manifest', {});
513
+ J.manifest = 'durable';
514
+ writeJournal(P.journal, J);
515
+ fp('cleanup:B:post-state-manifest-durable', {});
516
+ } else {
517
+ // 🔴 §5.6 前提 1 的推广(Codex 第二轮 P0-1):journal 说 `manifest = durable`
518
+ // **不算数**,每次进阶段 C 之前都要**从磁盘重验 manifest**。
519
+ // 只信 journal 的话,manifest 被删/被改之后照样会去删 retired/,
520
+ // 留下一个「已完成却无法 --from-generation 复位」的 generation;
521
+ // tar 也缺时旧树直接彻底丢失。
522
+ assertManifestUsable(mPath, J, P);
523
+ }
524
+
525
+ // ── 阶段 C ────────────────────────────────────────────────────────────────
526
+ for (const name of names) {
527
+ const it = J.items[name];
528
+ if (it.cleanup === 'done') continue;
529
+ if (it.had_old) {
530
+ // 🔴 删之前再看一眼 R 是什么:它要么还是完整旧树(正常)、要么是上一次删到一半
531
+ // 留下的部分树(续做)。**其它任何东西都不许删** —— 那是外部塞进来的现场证据
532
+ // (Codex 第三轮 (c)-3)。旧树本身此刻已在 tar 里,不构成丢失,但别人的数据会。
533
+ const R = join(P.retired, name);
534
+ if (existsSync(R)) {
535
+ const dR = safeTreeDigest(R);
536
+ if (dR !== it.old_digest && !isPartialOrAbsent(P, name, it)) {
537
+ bad(`阶段 C:retired/${name} 既不是完整旧树也不是「删到一半」的部分树,拒绝删除`);
538
+ }
539
+ }
540
+ fp('cleanup:C:pre-rmtree', { name });
541
+ rmtreeFsync(R);
542
+ fp('cleanup:C:post-rmtree', { name });
543
+ }
544
+ it.cleanup = 'done';
545
+ writeJournal(P.journal, J);
546
+ fp('cleanup:C:post-state-done', { name });
547
+ }
548
+
549
+ rmtreeFsync(P.tx);
550
+ fp('cleanup:post-tx-rm', {});
551
+ pruneAttic(P, keepGenerations);
552
+ J.phase = 'completed';
553
+ writeJournal(P.journal, J);
554
+ fp('cleanup:post-phase-completed', {});
555
+ const L = readLedger(P.ledger);
556
+ if (L.transaction !== null) writeLedger(P.ledger, { ...L, transaction: null });
557
+ fp('cleanup:post-clear-transaction', {});
558
+ // 🔴 `completed` 的 journal 是**残留**(§5.6 第 2 步表)。规范允许它由下一次运行清掉,
559
+ // 但那会让「恢复」这件事需要跑两趟才收敛 —— I7 要求一趟就到不动点。
560
+ // 因此正常路径在**清空 transaction 之后**顺手删掉它;崩在中间留下的那一份
561
+ // 仍由 recover 的 clearResidue 兜底。generation 水位在独立文件里,不受影响。
562
+ if (existsSync(P.journal)) rmtreeFsync(P.journal);
563
+ // §5.1 末尾:install / update / remove / recover 成功之后都要在 repo 锁下重算 lockfile。
564
+ // 「有没有注入」在入口就查过了(assertLockfileHook),这里只负责**调用**。
565
+ runLockfileRecalc(target, P, opts);
566
+ return J;
567
+ }
568
+
569
+ /**
570
+ * 🔴 只有**项目级** target 有 lockfile;判据取自账本自己的 `target.scope`,
571
+ * 不靠调用方口头声明。读不出账本时按最严格的一边(project)处理。
572
+ */
573
+ export function isProjectScope(P) {
574
+ try { return existsSync(P.ledger) ? readLedger(P.ledger).target.scope === 'project' : false; } catch { return true; }
575
+ }
576
+
577
+ /**
578
+ * 🔴 **入口就查,不要等到收尾**(自己踩的:第一版把它放在 runCleanup 末尾,
579
+ * 于是「忘了注入」在事务**已经提交完**之后才炸 —— 那时 journal 都删了,
580
+ * 下一次 recover 无事可做,lockfile 永远不会被重算)。
581
+ * 「缺少一个必需的输入」属于预检,应当在**放弃还免费**的时候报出来。
582
+ */
583
+ export function assertLockfileHook(P, opts) {
584
+ if (!isProjectScope(P)) return;
585
+ if (typeof opts.onLedgerChanged !== 'function') {
586
+ bad('项目级 target 必须注入 onLedgerChanged(§5.1:账本变更后要在 repo 锁下重算 lockfile)');
587
+ }
588
+ }
589
+
590
+ export function runLockfileRecalc(target, P, opts) {
591
+ if (!isProjectScope(P)) return;
592
+ assertLockfileHook(P, opts);
593
+ opts.onLedgerChanged(target);
594
+ }
595
+
596
+ export function safeTreeDigest(p) { try { return treeDigest(p); } catch { return null; } }
597
+
598
+ /**
599
+ * 🔴 §5.4.1 封闭表里 `R` 那一列的 **`部分|∅`** 到底是什么。
600
+ *
601
+ * 它指的是「阶段 C 的递归删除删到一半」留下的树,**不是**「任意一棵摘要不等于
602
+ * `old_digest` 的树」。后者可能是外部放进来的完整现场证据 —— 把它当成「部分」
603
+ * 会让 rollback / cleanup 直接删掉它。
604
+ *
605
+ * 可判定的判据:`R` 的**文件路径集合是归档里那棵旧树的真子集**。
606
+ * 递归删除只会让条目变少,绝不会变出新条目。
607
+ *
608
+ * ⚠️ **诚实边界**(Codex 第三轮 (b)):这只是「文件递归删除中断」的判据,
609
+ * **不是**「R 一定只是删到一半」的充分证明 —— `geoly-tree-v1` 与 tar 都不覆盖
610
+ * 空目录,所以「旧树 + 外部新增空目录」这类差异在这里看不见。
611
+ */
612
+ export function isPartialOrAbsent(P, name, it) {
613
+ const R = join(P.retired, name);
614
+ if (!existsSync(R)) return true;
615
+ const dR = safeTreeDigest(R);
616
+ if (dR === null) return true; // 读不出树 = 结构已破 = 部分
617
+ if (dR === it.old_digest) return false; // 完整旧树,不是「部分」
618
+ const tar = join(P.attic, `${name}.tar`);
619
+ if (!existsSync(tar)) return false;
620
+ let full;
621
+ try { full = new Set(parseTar(readFileSync(tar)).entries.map((e) => e.path)); } catch { return false; }
622
+ const cur = new Set();
623
+ try {
624
+ (function rec(d, rel) {
625
+ for (const n of readdirSync(d).sort()) {
626
+ const abs = join(d, n);
627
+ const r = rel === '' ? n : `${rel}/${n}`;
628
+ if (lstatSync(abs).isDirectory()) rec(abs, r); else cur.add(r);
629
+ }
630
+ })(R, '');
631
+ } catch { return true; }
632
+ if (cur.size >= full.size) return false;
633
+ for (const p of cur) if (!full.has(p)) return false;
634
+ return true; // 真子集 ⇒ 删到一半
635
+ }
636
+
637
+ /**
638
+ * 🔴 进入阶段 C 之前对 manifest 的完整可用性检查:文件在、schema 自洽、代号相符、
639
+ * **item 键集与本代 journal 一致**、每个非 install-new 项的 tar 在且摘要相符。
640
+ * 「文件在不在」不是判据 —— 判据是「它能不能真的用来复位这一代」。
641
+ */
642
+ function assertManifestUsable(mPath, J, P) {
643
+ if (!existsSync(mPath)) bad(`attic/${J.generation}/manifest.json 缺失,禁止进入清理阶段 C`);
644
+ const M = validateManifest(readJsonStrict(mPath, 'attic-manifest'));
645
+ if (M.generation !== J.generation) bad(`attic manifest 的 generation ${M.generation} != ${J.generation}`);
646
+ const want = [
647
+ ...Object.keys(J.items),
648
+ ...Object.keys(J.adopt_assertions ?? {}),
649
+ ...Object.keys(J.unadopt_assertions ?? {}),
650
+ ].sort();
651
+ const got = Object.keys(M.items).sort();
652
+ if (stringify(want) !== stringify(got)) {
653
+ bad(`attic manifest 的 items 键集与本代 journal 不一致(manifest ${got},journal ${want})`);
654
+ }
655
+ // 🔴 逐项**语义绑定**(Codex 第三轮 P0-1 未修尽处):只查「tar 非 null 的那些」不够 ——
656
+ // 把一个 swap 项改写成 `install-new + tar:null` 就能绕过检查,
657
+ // 然后阶段 C 照样删 retired,而那一代已经没有可用归档了。
658
+ for (const [name, mi] of Object.entries(M.items)) {
659
+ const it = J.items[name];
660
+ if (it) {
661
+ if (mi.op !== it.op) bad(`attic manifest 的 ${name}.op=${mi.op} 与 journal 的 ${it.op} 不符`);
662
+ if (mi.reverse_op !== REVERSE_OP[it.op]) bad(`attic manifest 的 ${name}.reverse_op 与 op 不互逆`);
663
+ if (it.op === 'install-new') {
664
+ if (mi.tar !== null || mi.old_digest !== null) bad(`attic manifest 的 ${name}:install-new 必须 tar/old_digest 皆 null`);
665
+ } else {
666
+ if (mi.tar !== `${name}.tar`) bad(`attic manifest 的 ${name}.tar 必须是 ${name}.tar`);
667
+ if (mi.old_digest !== it.old_digest) bad(`attic manifest 的 ${name}.old_digest 与 journal 不符`);
668
+ verifyArchive(join(P.attic, mi.tar), it.old_digest, P.state);
669
+ }
670
+ continue;
671
+ }
672
+ // 逻辑项(adopt / unadopt):没有旧树,manifest 里必须是 tar/old_digest 皆 null
673
+ const logical = (J.adopt_assertions?.[name] && 'adopt') || (J.unadopt_assertions?.[name] && 'unadopt');
674
+ if (!logical) bad(`attic manifest 记了 ${name},但它既不是本代物理项也不是逻辑项`);
675
+ if (mi.op !== logical) bad(`attic manifest 的 ${name}.op=${mi.op} 与逻辑项类型 ${logical} 不符`);
676
+ if (mi.reverse_op !== REVERSE_OP[logical]) bad(`attic manifest 的 ${name}.reverse_op 与 op 不互逆`);
677
+ if (mi.tar !== null || mi.old_digest !== null) bad(`attic manifest 的 ${name}:逻辑项必须 tar/old_digest 皆 null`);
678
+ }
679
+ return M;
680
+ }
681
+
682
+ /**
683
+ * 按保留代数清理旧 attic。🔴 `--keep-generations` **只管 `attic/`** ——
684
+ * `audit-archive/` 永不按代清理(它是审计历史,不是可丢弃的备份)。
685
+ * 🔴 冻结的代(`frozen_attic`)跳过。manifest 与该代全部 tar **一起**删。
686
+ */
687
+ export function pruneAttic(P, keep) {
688
+ if (!existsSync(P.atticDir)) return [];
689
+ const L = existsSync(P.ledger) ? readLedger(P.ledger) : null;
690
+ const gens = readdirSync(P.atticDir).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a);
691
+ const removed = [];
692
+ let kept = 0;
693
+ for (const g of gens) {
694
+ if (L && isGenerationFrozen(L, g)) continue; // 冻结的不占保留名额,也不删
695
+ kept++;
696
+ if (kept <= keep) continue;
697
+ rmtreeFsync(join(P.atticDir, String(g)));
698
+ removed.push(g);
699
+ }
700
+ return removed;
701
+ }
702
+
703
+ export function nowUtc(d = new Date()) {
704
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
705
+ }
706
+
707
+ // ════════════════════════════════════════════════════════════════════════════
708
+ // 第 5–10 步的编排
709
+ // ════════════════════════════════════════════════════════════════════════════
710
+
711
+ /**
712
+ * 🔴 本函数**不做**第 1–4 步(取锁、发现残留事务、预检、下载与验包)。
713
+ * 那是命令面的事;把它们混进来会诱使调用方以为「调一次就安全了」。
714
+ * 调用前必须已经过 recover 的第 2 步分流。
715
+ */
716
+ export function runTransaction(target, plan, opts = {}) {
717
+ const P = layout(target, plan.generation);
718
+ assertLockfileHook(P, opts); // 🔴 入口预检:缺必需输入要在动手之前报
719
+ stageTrees(P, plan);
720
+ const J = commitPoint(P, plan, opts);
721
+ applyItems(target, J, P);
722
+ verifyAndCommit(target, J, P);
723
+ runCleanup(target, J, P, opts);
724
+ return J;
725
+ }
726
+
727
+ /** 读回某一代 journal(供 recover 与测试用) */
728
+ export function loadJournal(target, gen) {
729
+ const P = layout(target, gen);
730
+ return { P, J: readJournal(P.journal) };
731
+ }
732
+