@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,1345 @@
1
+ // recover —— §5.2 第 2 步的分流、§5.4 幂等前向续做、§5.4.1 rollback、
2
+ // §5.8 `--from-generation` 的复位、§5.10 `--reinstall` 的 repair intent 状态机。
3
+ //
4
+ // 规格:04-install.md §5.2(十步的第 2 步:2a → 2b-1 → 2b-2 → 2c)、§5.4、§5.4.1、
5
+ // §5.4.2(双文件规则)、§5.5、§5.6、§5.8 / §5.8.1、§5.10;09-cli.md §1.1。
6
+ //
7
+ // 🔴 三条已经被踩过的坑,本模块处处遵守:
8
+ // · **绝不从正向 `item.state` 推断物理位置** —— 入场一律实测 T/R/S/A(§5.4.1 v35 铁律);
9
+ // · **「文件在不在」永远不是判据** —— attic 是否算恢复源要**重算内容**;
10
+ // · **预检不保证世界不会变** —— 每个动作点自己复验并 fail-closed(R-3)。
11
+
12
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { stringify } from './canonical-json.mjs';
15
+ import { treeDigest, txDigest } from './tree-digest.mjs';
16
+ import { writeAtomic, mkdirChainFsync, renameDirFsync, rmtreeFsync } from './atomic-fs.mjs';
17
+ import { fp } from './fault-inject.mjs';
18
+ import {
19
+ Corrupt, bad, readJournal, writeJournal, sweepTmp, listJournalGenerations,
20
+ assertConsistent, allItemKeys, readJsonStrict, assertKeys, assertSafeName, isUint, isTreeDigest, isTxDigest,
21
+ } from './journal.mjs';
22
+ import {
23
+ layout, readLedger, writeLedger, applyImageSide, resumeAuditArchive, maybeArchiveAudit,
24
+ dropOrExceptionLedger, sha256, nextGeneration, REPAIR_INTENT_SCHEMA,
25
+ DEFAULT_AUDIT_MAX_ENTRIES,
26
+ } from './ledger.mjs';
27
+ import {
28
+ applyItems, verifyAndCommit, runCleanup, idempotentRenameDir, verifyArchive, restoreArchive,
29
+ commitPoint, stageTrees, nowUtc, isPartialOrAbsent, assertLockfileHook, runLockfileRecalc,
30
+ } from './install.mjs';
31
+ import {
32
+ validateManifest, comparePostimage, REVERSE_OP, strictlyMatches,
33
+ } from './plan.mjs';
34
+
35
+ export { Corrupt };
36
+
37
+ /**
38
+ * 「有残留事务,需要人来选方向」不是 corrupt —— 退出码 5,措辞是提示不是报错。
39
+ */
40
+ export class NeedsRecover extends Error {
41
+ constructor(msg) { super(msg); this.name = 'NeedsRecover'; this.needsRecover = true; this.code = 5; }
42
+ }
43
+
44
+ /**
45
+ * 🔴 §3.4:`.geoly` 及其下全部状态路径必须以 **`lstat` 无跟随**方式打开,遇 symlink 即拒绝。
46
+ * §10 那句泛称的「拒绝路径链 symlink」只覆盖 target 本身,不足以覆盖状态目录 ——
47
+ * 把 `journal/` 或 `ledger.json` 换成 symlink 就能让我们去读/写 target 之外的东西。
48
+ *
49
+ * ⚠️ **这是入口门,不是原子保证**(同 R-1 / R-2):Node 不暴露 `openat`/`fstatat`,
50
+ * 检查与使用之间的窗口消不掉。它的价值是**尽早、集中地报出已知死路**。
51
+ */
52
+ export function assertStatePathsNoSymlink(P) {
53
+ const check = (p) => {
54
+ let st;
55
+ try { st = lstatSync(p); } catch (e) {
56
+ if (e?.code === 'ENOENT') return null;
57
+ bad(`.geoly 状态路径 ${p} 无法 lstat(${e.code})—— 看不见就不能声称它是安全的`);
58
+ return null;
59
+ }
60
+ if (st.isSymbolicLink()) bad(`.geoly 状态路径是 symlink,拒绝:${p}`);
61
+ return st;
62
+ };
63
+ const root = check(P.state);
64
+ if (root === null) return;
65
+ if (!root.isDirectory()) bad(`.geoly 不是普通目录:${P.state}`);
66
+ // 有界递归:状态目录不该有几万个条目;真有就说明现场不对,fail-closed 而不是硬扫
67
+ let budget = 50_000;
68
+ (function rec(dir, depth) {
69
+ for (const name of readdirSync(dir)) {
70
+ if (--budget < 0) bad('.geoly 下的条目数超过 5 万,拒绝继续扫描(现场不对)');
71
+ const abs = join(dir, name);
72
+ const st = check(abs);
73
+ if (st === null || !st.isDirectory()) continue;
74
+ // 🔴 `attic/` 与 `quarantine/` 必须**整棵**扫:manifest.json 与 *.tar 是被**直接读**的,
75
+ // 它们要是 symlink 就会让恢复从状态目录之外取证(Codex 第三轮 #18)。
76
+ // `tx-*` 的载荷由 treeDigest / txDigest 那一套无跟随遍历把关,只扫两层即可。
77
+ const deep = dir === P.state
78
+ ? ['attic', 'quarantine', 'audit-archive', 'journal'].includes(name)
79
+ : depth < 3;
80
+ if (deep || depth < 2) rec(abs, depth + 1);
81
+ }
82
+ })(P.state, 0);
83
+ }
84
+
85
+ // ════════════════════════════════════════════════════════════════════════════
86
+ // §5.2 第 2 步:分流
87
+ // ════════════════════════════════════════════════════════════════════════════
88
+
89
+ /**
90
+ * @param {string} target
91
+ * @param {object} opts
92
+ * @param {'auto'|'continue'|'rollback'|'reinstall'} [opts.mode]
93
+ * `auto` = 普通命令启动时那一趟(只自动续做清理与 pre-commit 丢弃,
94
+ * 遇到需要人选方向的残留就 `NeedsRecover` 停机)。
95
+ */
96
+ export function recover(target, opts = {}) {
97
+ const mode = opts.mode ?? 'auto';
98
+ const P0 = layout(target);
99
+ if (!existsSync(P0.state)) return { outcome: 'nothing' };
100
+ assertStatePathsNoSymlink(P0);
101
+ // 🔴 入口预检:项目级 target 缺 onLedgerChanged 就在这里报,不要等到收尾之后
102
+ assertLockfileHook(P0, opts);
103
+
104
+ // §5.4:journal 原子写失败留下的 `.tmp` 一律忽略并删除
105
+ sweepTmp(P0.state);
106
+ sweepTmp(P0.journalDir);
107
+
108
+ // ── 2a · 先清 audit intent ────────────────────────────────────────────────
109
+ // 🔴 存在 → **完成它,或 fail-closed 停机**。绝不跳过、绝不删除。
110
+ const audit = resumeAuditArchive(P0);
111
+
112
+ // ── 2b-1 · 先认 repair intent,再看普通残留 ───────────────────────────────
113
+ if (existsSync(P0.repairIntent)) {
114
+ if (mode !== 'reinstall') {
115
+ throw new NeedsRecover('存在未完成的 repair intent:请先跑 `recover --reinstall`');
116
+ }
117
+ const r = resumeRepair(target, opts);
118
+ return { outcome: 'repair', audit, ...r };
119
+ }
120
+
121
+ // ── 2b-2 · 通用分支 ───────────────────────────────────────────────────────
122
+ const gens = listJournalGenerations(P0.journalDir);
123
+ const txGens = readdirSync(P0.state)
124
+ .map((n) => /^tx-(\d+)$/.exec(n)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => a - b);
125
+
126
+ // 🔴 多代共存是**正常**的:completed 的 journal 是残留,不是损坏。
127
+ // 只有「不止一个未完成事务」才是真的 corrupt。
128
+ const live = [];
129
+ for (const g of gens) {
130
+ const J = readJournal(layout(target, g).journal);
131
+ if (J.phase !== 'completed') live.push({ g, J });
132
+ }
133
+ if (live.length > 1) bad(`同时存在 ${live.length} 个未完成事务:${live.map((x) => x.g).join(',')}`);
134
+
135
+ let result;
136
+ if (live.length === 0) {
137
+ result = clearResidue(target, P0, gens, txGens);
138
+ } else {
139
+ const { g, J } = live[0];
140
+ const P = layout(target, g);
141
+ result = driveLive(target, J, P, mode, opts);
142
+ }
143
+
144
+ // ── 2c · 确认已无未完成安装事务之后,才做阈值归档 ─────────────────────────
145
+ let archived = { outcome: 'noop' };
146
+ if (noUnfinishedInstallTx(target, P0)) {
147
+ archived = maybeArchiveAudit(P0, { maxEntries: opts.auditMaxEntries ?? DEFAULT_AUDIT_MAX_ENTRIES });
148
+ }
149
+ return { ...result, audit, archived };
150
+ }
151
+
152
+ function noUnfinishedInstallTx(target, P0) {
153
+ for (const g of listJournalGenerations(P0.journalDir)) {
154
+ if (readJournal(layout(target, g).journal).phase !== 'completed') return false;
155
+ }
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * §5.4.2 的双文件规则 + §5.6 第 2 步表的「`phase = completed` 的残留」。
161
+ * 没有任何未完成 journal 时可达的几种形状。
162
+ */
163
+ function clearResidue(target, P0, gens, txGens) {
164
+ let touched = false;
165
+ const completedGens = new Set(gens);
166
+ for (const g of txGens) {
167
+ const P = layout(target, g);
168
+ if (!completedGens.has(g)) {
169
+ // 有 tx 无 journal → **pre-commit**,允许直接删(结构上可证明未动 target)。
170
+ // 🔴 例外:repair-intent 指向该 tx 时绝不按 pre-commit 处理 —— 已在 2b-1 拦下。
171
+ rmtreeFsync(P.tx);
172
+ touched = true;
173
+ continue;
174
+ }
175
+ rmtreeFsync(P.tx); // completed 却留着 tx:直接清掉残留
176
+ touched = true;
177
+ }
178
+ if (existsSync(P0.ledger)) {
179
+ const L = readLedger(P0.ledger);
180
+ if (L.transaction !== null) {
181
+ // transaction ≠ null 而 journal 不存在(或已 completed)→ journal 权威,ledger 被修正
182
+ writeLedger(P0.ledger, { ...L, transaction: null });
183
+ touched = true;
184
+ }
185
+ }
186
+ // completed 的 journal 本身是残留,清掉(generation 水位在独立文件里,不受影响)
187
+ for (const g of gens) {
188
+ const P = layout(target, g);
189
+ if (existsSync(P.journal)) { rmtreeFsync(P.journal); touched = true; }
190
+ }
191
+ return { outcome: touched ? 'residue-cleared' : 'nothing' };
192
+ }
193
+
194
+ function driveLive(target, J, P, mode, opts) {
195
+ // 🔴 direction=rollback 一旦持久化,恢复**只能续做 rollback,不得转回正向**
196
+ if (J.direction === 'rollback') {
197
+ runRollback(target, J, P, opts);
198
+ return { outcome: 'resumed-rollback', generation: J.generation };
199
+ }
200
+ // 🔴 `cleanup_pending` **不等于**「只能往前」:§5.4.1 的一致性矩阵里
201
+ // `done + tar_durable` / `done + done` 两行本来就是给它准备的
202
+ // (旧树此刻在 retired/ 或 attic/)。用户显式选了方向就必须尊重(Codex 第二轮 #5)。
203
+ if (J.phase === 'cleanup_pending' && mode !== 'rollback') {
204
+ runCleanup(target, J, P, opts);
205
+ return { outcome: 'cleanup-finished', generation: J.generation };
206
+ }
207
+ if (J.phase !== 'prepared' && J.phase !== 'cleanup_pending') bad(`未知 phase ${J.phase}`);
208
+
209
+ const physCorrupt = Object.entries(J.items).filter(([, it]) => it.state === 'corrupt').map(([n]) => n);
210
+ const adoptBad = Object.entries(J.adopt_assertions ?? {})
211
+ .filter(([, a]) => a.state === 'assertion-corrupt').map(([n]) => n);
212
+ const unadoptBad = Object.entries(J.unadopt_assertions ?? {})
213
+ .filter(([, a]) => a.state === 'assertion-corrupt').map(([n]) => n);
214
+
215
+ if (mode === 'auto') {
216
+ throw new NeedsRecover(
217
+ `第 ${J.generation} 代事务停在 prepared:请跑 \`recover --continue\` 或 \`recover --rollback\``
218
+ + (physCorrupt.length ? `(物理 corrupt:${physCorrupt.join(',')} —— 只能 --reinstall 或人工)` : ''));
219
+ }
220
+
221
+ if (mode === 'rollback') {
222
+ // 🔴 混合异常的命令级优先级(§5.4 通用规则,写死)
223
+ if (physCorrupt.length) bad(`--rollback 拒绝:存在物理 corrupt(${physCorrupt.join(',')})`);
224
+ if (unadoptBad.length) {
225
+ bad(`--rollback 拒绝:unadopt 的 assertion-corrupt(${unadoptBad.join(',')})——`
226
+ + '恢复认领会让账本错误认领一棵非制品目录,唯一自动出路是 --continue');
227
+ }
228
+ beginRollback(target, J, P);
229
+ runRollback(target, J, P, opts);
230
+ return { outcome: 'rolled-back', generation: J.generation };
231
+ }
232
+
233
+ if (mode === 'continue') {
234
+ if (physCorrupt.length) bad(`--continue 拒绝:存在物理 corrupt(${physCorrupt.join(',')})`);
235
+ if (adoptBad.length) bad(`--continue 拒绝:adopt 的 assertion-corrupt(${adoptBad.join(',')})——出路是 --rollback`);
236
+ applyItems(target, J, P);
237
+ verifyAndCommit(target, J, P);
238
+ runCleanup(target, J, P, opts);
239
+ return { outcome: 'resumed-forward', generation: J.generation };
240
+ }
241
+
242
+ if (mode === 'reinstall') {
243
+ if (adoptBad.length || unadoptBad.length) {
244
+ bad('--reinstall 不自动执行:存在 assertion-corrupt(§5.4 通用规则),物理项也只能转人工');
245
+ }
246
+ if (!physCorrupt.length) bad('--reinstall 仅物理 corrupt 可用;当前没有物理 corrupt 项');
247
+ const r = beginRepair(target, J, P, opts);
248
+ return { outcome: 'repair', generation: J.generation, ...r };
249
+ }
250
+ bad(`未知的 recover 模式 ${mode}`);
251
+ return null;
252
+ }
253
+
254
+ /** §5.5 的报告:逐项报告,**不自动选、不猜** */
255
+ export function inspect(target) {
256
+ const P0 = layout(target);
257
+ const out = { generations: [], repairIntent: existsSync(P0.repairIntent), auditIntent: existsSync(P0.auditIntent) };
258
+ for (const g of listJournalGenerations(P0.journalDir)) {
259
+ const J = readJournal(layout(target, g).journal);
260
+ out.generations.push({
261
+ generation: g,
262
+ phase: J.phase,
263
+ direction: J.direction ?? null,
264
+ items: Object.fromEntries(Object.entries(J.items).map(([n, it]) =>
265
+ [n, { op: it.op, state: it.state, cleanup: it.cleanup ?? null }])),
266
+ adopt: Object.fromEntries(Object.entries(J.adopt_assertions ?? {}).map(([n, a]) => [n, a.state])),
267
+ unadopt: Object.fromEntries(Object.entries(J.unadopt_assertions ?? {}).map(([n, a]) => [n, a.state])),
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // ════════════════════════════════════════════════════════════════════════════
274
+ // §5.4.1 rollback
275
+ // ════════════════════════════════════════════════════════════════════════════
276
+
277
+ /** 实测 T / R / S / A。🔴 attic 是否算「可用恢复源」必须**重验内容**。 */
278
+ function observe(target, J, P, name) {
279
+ const it = J.items[name];
280
+ const T = join(target, name), R = join(P.retired, name), S = join(P.stage, name);
281
+ const digestOf = (p) => { if (!existsSync(p)) return null; try { return treeDigest(p); } catch { return 'unreadable'; } };
282
+ const dT = digestOf(T), dR = digestOf(R), dS = digestOf(S);
283
+ let aOk = false;
284
+ if (it && it.op !== 'install-new') {
285
+ const tar = join(P.attic, `${name}.tar`);
286
+ if (existsSync(tar)) {
287
+ try { verifyArchive(tar, it.old_digest, P.state); aOk = true; } catch { aOk = false; }
288
+ }
289
+ }
290
+ return { T, R, S, dT, dR, dS, aOk, hasR: existsSync(R), hasS: existsSync(S) };
291
+ }
292
+
293
+ /**
294
+ * §5.4.1「入场分类的封闭表」。🔴 **正向 `item.state` 只用于一致性校验,不参与判定。**
295
+ * 未列组合一律 `corrupt`。表里的 `S` 列也是判据的一部分(Codex 第二轮 #3)。
296
+ */
297
+ export function classifyEntry(target, J, P, name) {
298
+ const it = J.items[name];
299
+ const o = observe(target, J, P, name);
300
+ const where = `${name}:T=${o.dT} R=${o.dR} S=${o.dS} A=${o.aOk}`;
301
+
302
+ if (it.op === 'install-new') {
303
+ // 🔴 本表**只实测 T**(规格的 install-new 表只有一列)
304
+ if (o.dT === null) return 'noop';
305
+ if (o.dT === it.new_digest) return 'as-installed';
306
+ return bad(`install-new 入场分类不合法 ${where}`);
307
+ }
308
+ if (it.op === 'retire-only') {
309
+ // 表:(old, ∅, *) / (∅, old, *) / (∅, 部分|∅, old)
310
+ if (o.dT === it.old_digest && !o.hasR) return 'noop';
311
+ if (o.dT === null && o.dR === it.old_digest) return 'as-retired';
312
+ if (o.dT === null && o.aOk && isPartialOrAbsent(P, name, it)) return 'as-retired-cleaned';
313
+ return bad(`retire-only 入场分类不合法 ${where}`);
314
+ }
315
+ // op = swap —— 表:(old,∅,new,*) / (∅,old,new,*) / (new,old,∅,*) / (new,部分|∅,∅,old)
316
+ if (o.dT === it.old_digest && !o.hasR && o.dS === it.new_digest) return 'noop';
317
+ if (o.dT === null && o.dR === it.old_digest && o.dS === it.new_digest) return 'as-retired';
318
+ if (o.dT === it.new_digest && o.dR === it.old_digest && !o.hasS) return 'as-swapped';
319
+ if (o.dT === it.new_digest && !o.hasS && o.aOk && isPartialOrAbsent(P, name, it)) return 'as-swapped-cleaned';
320
+ return bad(`swap 入场分类不合法 ${where}`);
321
+ }
322
+
323
+ /** 该 entry_class 的恢复源与是否要 park T(封闭表的后两列) */
324
+ const CLASS_SPEC = {
325
+ noop: { source: null, park: false },
326
+ 'as-retired': { source: 'R', park: false },
327
+ 'as-retired-cleaned': { source: 'A', park: false },
328
+ 'as-swapped': { source: 'R', park: true },
329
+ 'as-swapped-cleaned': { source: 'A', park: true },
330
+ 'as-installed': { source: null, park: true },
331
+ };
332
+
333
+ /**
334
+ * 🔴 **入场预检在持久化 `direction=rollback` 之前全部做完** ——
335
+ * 任一不满足就**拒绝整个 rollback,不写 `direction`**,
336
+ * 否则会把一个注定做不完的回滚**锁死在 rollback 方向**。
337
+ */
338
+ export function beginRollback(target, J, P) {
339
+ const { phys, ad, un } = allItemKeys(J);
340
+ const rb = { items: {} };
341
+
342
+ for (const name of phys.sort()) {
343
+ const it = J.items[name];
344
+ const cls = classifyEntry(target, J, P, name);
345
+ // (op, state, cleanup, entry_class) 闭合一致性矩阵 —— 只校验,不调度
346
+ assertConsistent(name, it, cls);
347
+ const spec = CLASS_SPEC[cls];
348
+ const o = observe(target, J, P, name);
349
+ if (spec.source === 'R' && o.dR !== it.old_digest) bad(`入场预检:${name} 选 R 但 R 摘要不符`);
350
+ if (spec.source === 'A' && !o.aOk) bad(`入场预检:${name} 选 A 但 attic 归档不可用或摘要不符`);
351
+ // 🔴 要被移走的 T:**按实测判定,不看正向 state**;最终以封闭表的 park 列为准
352
+ if (spec.park) {
353
+ // 三个要 park 的分类(as-swapped / as-swapped-cleaned / as-installed)park 的都是**新树**
354
+ if (o.dT !== it.new_digest) bad(`入场预检:${name} 按分类要 park T,但实测 T=${o.dT} != ${it.new_digest}`);
355
+ } else if (o.dT !== null && it.op !== 'install-new' && o.dT !== it.old_digest) {
356
+ bad(`入场预检:${name} 不需要 park,但实测 T=${o.dT} 既不是旧树也不缺席`);
357
+ }
358
+ rb.items[name] = { entry_class: cls, rstate: cls === 'noop' ? 'restored' : 'pending' };
359
+ }
360
+ for (const name of ad) {
361
+ const a = J.adopt_assertions[name];
362
+ if (a.state === 'ok') {
363
+ const m = strictlyMatches(join(target, name), a.tree_digest);
364
+ if (!m.ok) bad(`adopt[${name}] 入场分类不合法:${m.why}`);
365
+ }
366
+ // 🔴 assertion-corrupt 时**不检查 T**(v42 修:否则「允许 rollback」自己堵死)
367
+ rb.items[name] = { entry_class: 'noop', rstate: 'restored' };
368
+ }
369
+ for (const name of un) {
370
+ const a = J.unadopt_assertions[name];
371
+ if (a.state !== 'ok') bad(`unadopt[${name}] 的 assertion-corrupt 不允许 rollback`);
372
+ const m = strictlyMatches(join(target, name), a.tree_digest);
373
+ if (!m.ok) bad(`unadopt[${name}] 入场分类不合法:${m.why}`);
374
+ rb.items[name] = { entry_class: 'noop', rstate: 'restored' };
375
+ }
376
+
377
+ fp('rollback:pre-direction', {});
378
+ // 🔴 `direction = rollback` 与全部 entry_class / 初始 rstate **必须在同一次原子写里落盘**,
379
+ // 写完才允许动手。
380
+ J.direction = 'rollback';
381
+ J.rollback = rb;
382
+ writeJournal(P.journal, J);
383
+ fp('rollback:post-direction', {});
384
+ return J;
385
+ }
386
+
387
+ /** 🔴 逐项反向段:**以 `entry_class` + `rstate` 调度,正向 `state` 不参与**。 */
388
+ export function runRollback(target, J, P, opts = {}) {
389
+ if (J.direction !== 'rollback') bad('runRollback:direction 不是 rollback');
390
+ for (const name of Object.keys(J.rollback.items).sort()) {
391
+ const r = J.rollback.items[name];
392
+ if (r.rstate === 'restored') continue;
393
+ const it = J.items[name];
394
+ if (!it) bad(`rollback:${name} 是逻辑项却不是 restored`);
395
+ const T = join(target, name), R = join(P.retired, name), S = join(P.stage, name);
396
+ const cls = r.entry_class;
397
+
398
+ // 「cleaned」两类:先从 A 重建 R,再走与非 cleaned 相同的后续段
399
+ if ((cls === 'as-retired-cleaned' || cls === 'as-swapped-cleaned') && r.rstate === 'pending') {
400
+ if (!existsSync(R) || safeDigest(R) !== it.old_digest) {
401
+ const tar = join(P.attic, `${name}.tar`);
402
+ verifyArchive(tar, it.old_digest, P.state);
403
+ const un = join(P.unpack, name);
404
+ restoreArchive(tar, un, it.old_digest);
405
+ if (existsSync(R)) rmtreeFsync(R); // 部分树先清掉,再把重建好的整棵搬过去
406
+ mkdirChainFsync(P.retired);
407
+ renameDirFsync(un, R);
408
+ }
409
+ }
410
+
411
+ if (CLASS_SPEC[cls].park && r.rstate === 'pending') {
412
+ fp('rollback:item:pre-park-t', { name });
413
+ if (cls === 'as-installed') {
414
+ // 🔴 `install-new` 的反向**不得在 target 内递归删除** —— park 到 `undo/`
415
+ mkdirChainFsync(P.undo);
416
+ idempotentRenameDir(T, join(P.undo, name), it.new_digest);
417
+ } else {
418
+ mkdirChainFsync(P.stage);
419
+ idempotentRenameDir(T, S, it.new_digest);
420
+ }
421
+ fp('rollback:item:post-park-t', { name });
422
+ r.rstate = cls === 'as-installed' ? 'restored' : 't_parked';
423
+ writeJournal(P.journal, J);
424
+ fp('rollback:item:post-rstate', { name });
425
+ if (r.rstate === 'restored') continue;
426
+ }
427
+
428
+ fp('rollback:item:pre-restore', { name });
429
+ idempotentRenameDir(R, T, it.old_digest);
430
+ fp('rollback:item:post-restore', { name });
431
+ r.rstate = 'restored';
432
+ writeJournal(P.journal, J);
433
+ fp('rollback:item:post-rstate', { name });
434
+ }
435
+ finalizeRollback(target, J, P, opts);
436
+ return J;
437
+ }
438
+
439
+ function safeDigest(p) { try { return treeDigest(p); } catch { return null; } }
440
+
441
+
442
+
443
+ /**
444
+ * 终结顺序(§5.4.1,每步 fsync;本身也要可崩溃续做):
445
+ * ① 删除**本代**的 attic/<gen>/ 与其 manifest
446
+ * ② 写账本(ledger_existed 决定 patch 还是删库/例外账本)
447
+ * ③ 重算 lockfile(在 repo 锁下 —— 由调用方通过 opts.onLedgerChanged 接线)
448
+ * ④ 清理 <tx>/undo/、<tx>/unpack/ 与整个 tx 目录
449
+ * ⑤ journal:**同一次原子写删除 direction 与 rollback**,phase = completed
450
+ */
451
+ function finalizeRollback(target, J, P, opts) {
452
+ // ① 🔴 阶段 B 之后回滚时 manifest 已经写出来了;不删它就会留下一个看起来
453
+ // 「已完成」的 generation,之后被 --from-generation 误用
454
+ if (existsSync(P.attic)) rmtreeFsync(P.attic);
455
+
456
+ // ②
457
+ const { ledger_existed: existed, pre } = J.ledger_image;
458
+ if (existed) {
459
+ const L = readLedger(P.ledger);
460
+ writeLedger(P.ledger, { ...applyImageSide(L, pre), transaction: null });
461
+ } else {
462
+ dropOrExceptionLedger(P);
463
+ }
464
+ // ③ 🔴 §5.1 末尾:**`recover` 的任一子操作成功后,也必须在 repo 锁下重算项目 lockfile**。
465
+ // 「有没有注入」在 recover 入口就查过了;这里只负责调用(Codex 第二轮 #6)。
466
+ runLockfileRecalc(target, P, opts);
467
+
468
+ // ④
469
+ if (existsSync(P.undo)) rmtreeFsync(P.undo);
470
+ if (existsSync(P.unpack)) rmtreeFsync(P.unpack);
471
+ if (existsSync(P.tx)) rmtreeFsync(P.tx);
472
+
473
+ // ⑤ 🔴 同一次原子写删掉两者
474
+ delete J.direction;
475
+ delete J.rollback;
476
+ J.phase = 'completed';
477
+ writeJournal(P.journal, J);
478
+ fp('rollback:post-finalize', {});
479
+ // 随后按常规清掉 completed journal
480
+ if (existsSync(P.journal)) rmtreeFsync(P.journal);
481
+ if (existsSync(P.ledger)) {
482
+ const L = readLedger(P.ledger);
483
+ if (L.transaction !== null) writeLedger(P.ledger, { ...L, transaction: null });
484
+ }
485
+ }
486
+
487
+ // ════════════════════════════════════════════════════════════════════════════
488
+ // §5.8 `--from-generation`:**不是回滚,是一次新的正向事务**
489
+ // ════════════════════════════════════════════════════════════════════════════
490
+
491
+ /**
492
+ * ① 读 manifest → ② 按 §5.8.1 做 postimage 三方比对(不过即拒绝)→
493
+ * ③ 开一个**新的正向事务**,逐项按各自的 `reverse_op` → ④ 收尾提交过滤后的 delta。
494
+ *
495
+ * @param {string[]} [opts.only] 🔴 可重复;闭包不完整时**拒绝并列出**要一起选的全部 name
496
+ */
497
+ export function planFromGeneration(target, N, opts = {}) {
498
+ const P = layout(target, N);
499
+ const mPath = join(P.attic, 'manifest.json');
500
+ if (!existsSync(mPath)) bad(`第 ${N} 代不可复位:attic/${N}/manifest.json 缺失`);
501
+ const M = validateManifest(readJsonStrict(mPath, 'attic-manifest'));
502
+ if (M.generation !== N) bad(`attic manifest 的 generation ${M.generation} != ${N}`);
503
+ const L = readLedger(P.ledger);
504
+ const { conflicts, selection } = comparePostimage(M, L, target, { only: opts.only });
505
+ if (conflicts.length) {
506
+ const nearer = readdirSync(P.atticDir).filter((n) => /^\d+$/.test(n)).map(Number)
507
+ .filter((g) => g > N).sort((a, b) => a - b);
508
+ bad(`第 ${N} 代的 postimage 三方比对不通过:\n ${conflicts.join('\n ')}`
509
+ + (nearer.length ? `\n仍有 manifest 的更近 generation:${nearer.join(',')}(**条件提示,不是承诺**)` : ''));
510
+ }
511
+
512
+ // 逐项按 reverse_op 编译成一个普通的正向计划
513
+ const items = {};
514
+ const sources = {};
515
+ const adopt = {};
516
+ const unadopt = {};
517
+ for (const name of selection.items) {
518
+ const mi = M.items[name];
519
+ if (mi.reverse_op === 'unadopt') { unadopt[name] = adoptFieldsFor(M, name, 'unadopt'); continue; }
520
+ if (mi.reverse_op === 'adopt') { adopt[name] = adoptFieldsFor(M, name, 'adopt'); continue; }
521
+ if (!existsSync(join(target, name)) && mi.reverse_op !== 'install-new') {
522
+ // reverse_op = swap 需要 target 上此刻就是本代装上去的那棵树;比对已保证了这一点
523
+ bad(`复位 ${name}:reverse_op=${mi.reverse_op} 但 target 上没有这个目录`);
524
+ }
525
+ items[name] = { reverse_op: mi.reverse_op, tar: mi.tar, old_digest: mi.old_digest };
526
+ if (mi.tar) sources[name] = join(P.attic, mi.tar);
527
+ }
528
+ return { manifest: M, selection, reverseItems: items, sources, adopt, unadopt };
529
+ }
530
+
531
+ /**
532
+ * 🔴 `unadopt → adopt` 的字段映射与等式(v43):
533
+ * `artifact` 取自 **`ledger_delta.entries[name].artifact`**;
534
+ * `tree_digest` 必须 == 该 entry 的 digest,且 == `postimage.digests[name]` 的 D;
535
+ * 🔴 **不得取 `old_digest`** —— 它在 unadopt manifest 里是 `null`。
536
+ */
537
+ function adoptFieldsFor(M, name, want) {
538
+ const d = M.postimage.digests[name];
539
+ if (want === 'adopt') {
540
+ const e = M.ledger_delta.entries[name];
541
+ if (!e) bad(`复位 ${name}:ledger_delta.entries[${name}] 缺失,无法重新认领`);
542
+ if (!d || d.present !== true) bad(`复位 ${name}:postimage.digests 未记录 present:true`);
543
+ if (e.tree_digest !== d.digest) bad(`复位 ${name}:entry 的 digest 与 postimage 的 D 不等`);
544
+ return { artifact: e.artifact, state: 'ok', tree_digest: e.tree_digest };
545
+ }
546
+ // unadopt:撤销认领,字段来自本代 postimage 里那条 entry
547
+ const e = M.postimage.entries[name];
548
+ if (!e) bad(`复位 ${name}:postimage.entries[${name}] 缺失,无法撤销认领`);
549
+ return { artifact: e.artifact, state: 'ok', tree_digest: e.tree_digest };
550
+ }
551
+
552
+ // ════════════════════════════════════════════════════════════════════════════
553
+ // §5.10 repair intent
554
+ // ════════════════════════════════════════════════════════════════════════════
555
+
556
+ const INTENT_STATES = ['planned', 'isolated', 'child_registered', 'child_done', 'done'];
557
+
558
+ export function validateRepairIntent(I) {
559
+ assertKeys(I, ['schema', 'repair_id', 'generation', 'isolate', 'plan', 'state', 'created_at'],
560
+ ['child'], 'repair-intent');
561
+ if (I.schema !== REPAIR_INTENT_SCHEMA) bad(`repair-intent.schema 必须是 ${REPAIR_INTENT_SCHEMA}`);
562
+ if (typeof I.repair_id !== 'string' || I.repair_id === '') bad('repair-intent.repair_id 必填');
563
+ if (!isUint(I.generation)) bad('repair-intent.generation 必须是非负整数');
564
+ if (!INTENT_STATES.includes(I.state)) bad(`repair-intent.state 未知取值 ${I.state}`);
565
+
566
+ assertKeys(I.isolate, ['tx', 'journal', 'ledger_transaction', 'targets'], [], 'repair-intent.isolate');
567
+ assertKeys(I.isolate.tx, ['dir', 'fingerprint'], [], 'repair-intent.isolate.tx');
568
+ if (I.isolate.tx.dir !== `tx-${I.generation}`) bad('repair-intent.isolate.tx.dir 与 generation 不一致');
569
+ if (!isTxDigest(I.isolate.tx.fingerprint)) bad('repair-intent.isolate.tx.fingerprint 必须是 geoly-tx-v1 摘要');
570
+ assertKeys(I.isolate.journal, ['path', 'digest'], [], 'repair-intent.isolate.journal');
571
+ if (I.isolate.journal.path !== `journal/${I.generation}.json`) bad('repair-intent.isolate.journal.path 与 generation 不一致');
572
+ assertKeys(I.isolate.ledger_transaction, ['digest'], [], 'repair-intent.isolate.ledger_transaction');
573
+ for (const [n, t] of Object.entries(I.isolate.targets)) {
574
+ assertSafeName(n, 'repair-intent.isolate.targets 的键');
575
+ assertKeys(t, ['observed'], [], `repair-intent.isolate.targets[${n}]`);
576
+ if (!isTreeDigest(t.observed)) bad(`repair-intent.isolate.targets[${n}].observed 必须是树摘要`);
577
+ }
578
+
579
+ assertKeys(I.plan, ['snapshot', 'items', 'repair_ledger_image'], [], 'repair-intent.plan');
580
+ if (!isUint(I.plan.snapshot)) bad('repair-intent.plan.snapshot 必须是非负整数');
581
+ const rli = I.plan.repair_ledger_image;
582
+ assertKeys(rli, ['closure_entries', 'closure_roots', 'pre', 'post'], [], 'repair-intent.plan.repair_ledger_image');
583
+ for (const k of ['closure_entries', 'closure_roots']) {
584
+ if (!Array.isArray(rli[k]) || rli[k].some((x) => typeof x !== 'string')) bad(`repair_ledger_image.${k} 必须是字符串数组`);
585
+ }
586
+ for (const side of ['pre', 'post']) {
587
+ assertKeys(rli[side], ['entries', 'roots'], [], `repair_ledger_image.${side}`);
588
+ // 🔴 repair-intent 文件自身用「字段缺席」表达「应不存在」,**不写 null**
589
+ for (const m of ['entries', 'roots']) {
590
+ for (const [k, v] of Object.entries(rli[side][m])) {
591
+ if (v === null) bad(`repair_ledger_image.${side}.${m}[${k}] 不得写 null(用字段缺席)`);
592
+ }
593
+ }
594
+ }
595
+ // planned 阶段与 isolated 之后的**字段集定死,多一个少一个都算非法 intent**
596
+ const planned = I.state === 'planned';
597
+ for (const [n, p] of Object.entries(I.plan.items)) {
598
+ const where = `repair-intent.plan.items[${n}]`;
599
+ assertSafeName(n, 'repair-intent.plan.items 的键');
600
+ const req = planned ? ['target'] : ['target', 'cur', 'child_op'];
601
+ assertKeys(p, req, planned ? ['old_digest'] : ['old_digest', 'restore_from'], where);
602
+ assertKeys(p.target, ['present'], ['digest'], `${where}.target`);
603
+ if (typeof p.target.present !== 'boolean') bad(`${where}.target.present 必须是布尔`);
604
+ if (p.target.present && !isTreeDigest(p.target.digest)) bad(`${where}.target.digest 必须是树摘要`);
605
+ if (!p.target.present && 'digest' in p.target) bad(`${where}.target:present=false 时不得带 digest`);
606
+ if ('old_digest' in p && !isTreeDigest(p.old_digest)) bad(`${where}.old_digest 必须是树摘要`);
607
+ if (!planned) {
608
+ if (!['absent', 'old', 'target', 'other'].includes(p.cur)) bad(`${where}.cur 未知取值 ${p.cur}`);
609
+ if (!['swap', 'install-new', 'retire-only', 'logical-only'].includes(p.child_op)) {
610
+ bad(`${where}.child_op 未知取值 ${p.child_op}`);
611
+ }
612
+ const needs = p.child_op === 'swap' || p.child_op === 'install-new';
613
+ if (needs && !p.restore_from) bad(`${where}.restore_from 在 child_op=${p.child_op} 时必填`);
614
+ if (!needs && 'restore_from' in p) bad(`${where}.restore_from 只在 install-new / swap 时出现`);
615
+ if (p.restore_from) {
616
+ assertKeys(p.restore_from, ['artifact', 'tree_digest', 'source'], ['slot'], `${where}.restore_from`);
617
+ if (!isTreeDigest(p.restore_from.tree_digest)) bad(`${where}.restore_from.tree_digest 必须是树摘要`);
618
+ // 🔴 必须 == target.digest
619
+ if (p.restore_from.tree_digest !== p.target.digest) bad(`${where}.restore_from.tree_digest != target.digest`);
620
+ const srcOk = p.restore_from.source === 'registry' || p.restore_from.source === 'quarantine'
621
+ || p.restore_from.source === 'quarantine-tx' || /^attic\/\d+$/.test(p.restore_from.source);
622
+ if (!srcOk) bad(`${where}.restore_from.source 未知取值 ${p.restore_from.source}`);
623
+ if (p.restore_from.source === 'quarantine-tx') {
624
+ if (!['stage', 'retired', 'undo'].includes(p.restore_from.slot)) {
625
+ bad(`${where}.restore_from.slot 在 source=quarantine-tx 时必填且只能是 stage/retired/undo`);
626
+ }
627
+ } else if ('slot' in p.restore_from) bad(`${where}.restore_from.slot 只在 source=quarantine-tx 时出现`);
628
+ }
629
+ }
630
+ }
631
+ if ('child' in I) {
632
+ assertKeys(I.child, ['generation', 'tx_dir', 'committed'], [], 'repair-intent.child');
633
+ if (!isUint(I.child.generation)) bad('repair-intent.child.generation 必须是非负整数');
634
+ if (I.child.tx_dir !== `tx-${I.child.generation}`) bad('repair-intent.child.tx_dir 与 generation 不一致');
635
+ if (typeof I.child.committed !== 'boolean') bad('repair-intent.child.committed 必须是布尔');
636
+ }
637
+ // 🔴 `state` × `child` 的合法组合(其余一律 fail-closed)
638
+ const hasChild = 'child' in I;
639
+ const legal = { planned: false, isolated: false, child_registered: true, child_done: true, done: true };
640
+ if (legal[I.state] !== hasChild) bad(`repair-intent:state=${I.state} 与 child ${hasChild ? '存在' : '缺席'} 的组合非法`);
641
+ return I;
642
+ }
643
+
644
+ const readIntent = (P) => validateRepairIntent(readJsonStrict(P.repairIntent, 'repair-intent'));
645
+ const writeIntent = (P, I) => { validateRepairIntent(I); writeAtomic(P.repairIntent, stringify(I)); };
646
+
647
+ /** quarantine 的四个落点(§5.10「隔离的范围」)*/
648
+ function quarPaths(P) {
649
+ return {
650
+ root: P.quarantine,
651
+ tx: join(P.quarantine, 'tx'), // 🔴 规格是 quarantine/<gen>/tx/
652
+ journal: join(P.quarantine, 'journal.json'),
653
+ targets: join(P.quarantine, 'targets'),
654
+ };
655
+ }
656
+
657
+ /** §5.10 ①②:枚举完整 plan,写 intent(state=planned)*/
658
+ export function beginRepair(target, J, P, opts = {}) {
659
+ const gen = J.generation;
660
+ const L = readLedger(P.ledger);
661
+ if (L.transaction === null) bad('repair:ledger.transaction 为 null,无法绑定被隔离的事务');
662
+ if (L.transaction.generation !== gen) bad('repair:ledger.transaction 属于他代');
663
+
664
+ // 🔴 只隔离**因自身 corrupt 而不可信**的 target 树(§5.10「隔离的范围」第四行)。
665
+ // 判据不是「这一项是 corrupt」,而是「那棵树既不是旧树也不是新树」——
666
+ // 树本身可信时把它一起搬走,等于替用户挪了一个完好的目录。
667
+ const targets = {};
668
+ for (const [n, it] of Object.entries(J.items)) {
669
+ if (it.state !== 'corrupt') continue;
670
+ const dir = join(target, n);
671
+ if (!existsSync(dir)) continue;
672
+ let d;
673
+ try { d = treeDigest(dir); } catch { d = null; }
674
+ if (d !== null && (d === it.old_digest || d === it.new_digest)) continue;
675
+ if (d === null) bad(`repair:${n} 的目标树无法成像,不写 intent、转人工`);
676
+ targets[n] = { observed: d };
677
+ }
678
+
679
+ // 🔴 `repair_ledger_image` 在**第 ① 步、任何隔离动作之前**计算并持久化
680
+ const rli = computeRepairLedgerImage(L, J);
681
+ // 校验:当前账本在 closure 上的投影必须精确等于 pre
682
+ const projNow = projection(L, rli.closure_entries, rli.closure_roots);
683
+ if (stringify(projNow) !== stringify(rli.pre)) bad('repair:当前账本在 closure 上的投影与 repair_ledger_image.pre 不等');
684
+
685
+ const items = {};
686
+ for (const name of Object.keys(J.items).sort()) {
687
+ const it = J.items[name];
688
+ const e = rli.post.entries[name];
689
+ const tgt = e ? { digest: e.tree_digest, present: true } : { present: false };
690
+ // 🔴 与原 journal 意图严格对应:两者不一致即 corrupt
691
+ const wantDigest = it.op === 'retire-only' ? undefined : it.new_digest;
692
+ if (tgt.present ? tgt.digest !== wantDigest : wantDigest !== undefined) {
693
+ bad(`repair:${name} 的目标断言与原 journal 意图不一致`);
694
+ }
695
+ const p = { target: tgt };
696
+ if (it.op !== 'install-new') p.old_digest = it.old_digest;
697
+ items[name] = p;
698
+ }
699
+
700
+ const I = {
701
+ schema: REPAIR_INTENT_SCHEMA,
702
+ created_at: opts.now ?? nowUtc(),
703
+ generation: gen,
704
+ isolate: {
705
+ journal: { digest: sha256(readFileSync(P.journal)), path: `journal/${gen}.json` },
706
+ ledger_transaction: { digest: sha256(Buffer.from(stringify(L.transaction), 'utf8')) },
707
+ targets,
708
+ // 🔴 `geoly-tx-v1`:目录项也进摘要(含空目录、含 tx 根本身)
709
+ tx: { dir: `tx-${gen}`, fingerprint: txDigest(P.tx) },
710
+ },
711
+ plan: { items, repair_ledger_image: rli, snapshot: bindSnapshot(J, opts) },
712
+ repair_id: opts.repairId ?? sha256(Buffer.from(`${target}|${gen}|${I0seed()}`, 'utf8')).slice(7, 23),
713
+ state: 'planned',
714
+ };
715
+ writeIntent(P, I);
716
+ fp('repair:step2:post-intent', { gen });
717
+ return resumeRepair(target, { ...opts, mode: 'reinstall' });
718
+ function I0seed() { return opts.now ?? nowUtc(); }
719
+ }
720
+
721
+ /**
722
+ * 🔴 `plan.snapshot` 的绑定(§5.10):它必须等于**原 journal 记录的解析快照**,
723
+ * 不能是「随便挑一个当前快照」(Codex 第二轮 #8)。
724
+ *
725
+ * 原 journal 里记着解析快照的地方是 `ledger_image.post.entries[*].snapshot` ——
726
+ * 一个事务里的成员来自同一次解析,因此该值必须唯一;不唯一或缺失即 corrupt。
727
+ * 调用方传了 `opts.snapshot` 就必须与它相等(多一个来源 = 多一份真相)。
728
+ * 🔴 还要能按 §02-6.1 的历史读取路径取回并验签 —— 那条链不在内核范围,
729
+ * 由 `opts.assertSnapshotRetrievable(N)` 注入;不注入就如实拒绝自动 repair。
730
+ */
731
+ function bindSnapshot(J, opts) {
732
+ // 🔴 **只取 `post`**(Codex 第三轮 #8):`pre` 记的是**上一次**安装时的快照,
733
+ // 正常的升级事务里 pre 与 post 本来就不同 —— 把两者并起来要求唯一,
734
+ // 会把每一个合法的升级事务都判成「快照不唯一」而拒掉自动 repair。
735
+ const seen = new Set();
736
+ for (const e of Object.values(J.ledger_image.post.entries)) if (e) seen.add(e.snapshot);
737
+ if (seen.size === 0) bad('repair:原 journal 的 post 里找不到解析快照,plan.snapshot 无从绑定,转人工');
738
+ if (seen.size > 1) bad(`repair:原 journal 的 post 里解析快照不唯一(${[...seen].join(',')}),转人工`);
739
+ const n = [...seen][0];
740
+ if (opts.snapshot !== undefined && opts.snapshot !== n) {
741
+ bad(`repair:opts.snapshot=${opts.snapshot} 与原 journal 记录的解析快照 ${n} 不符`);
742
+ }
743
+ // 🔴 §5.10:该快照必须能按 §02-6.1 的历史读取路径**取回并验签**。
744
+ // 那条链不在内核范围,只能注入 —— 但**不注入不等于放行**(注释说了拒绝就要真拒绝)。
745
+ if (typeof opts.assertSnapshotRetrievable !== 'function') {
746
+ bad('repair:必须注入 assertSnapshotRetrievable(N)(plan.snapshot 要能取回并验签,§5.10)');
747
+ }
748
+ opts.assertSnapshotRetrievable(n);
749
+ return n;
750
+ }
751
+
752
+ /** §5.10「投影」的正式定义 */
753
+ function projection(L, closureEntries, closureRoots) {
754
+ const entries = {}, roots = {};
755
+ for (const k of closureEntries) if (Object.hasOwn(L.entries, k)) entries[k] = L.entries[k];
756
+ for (const k of closureRoots) if (Object.hasOwn(L.roots, k)) roots[k] = L.roots[k];
757
+ return { entries, roots };
758
+ }
759
+
760
+ /**
761
+ * 输入 = **原 journal 的 `ledger_image`(pre 与 post 的并集)+ 当前 ledger**;
762
+ * 在 **root ↔ entry 二部图**上,从受影响 seed 取**不动点闭包**。
763
+ */
764
+ function computeRepairLedgerImage(L, J) {
765
+ const seedEntries = new Set([
766
+ ...Object.keys(J.ledger_image.pre.entries), ...Object.keys(J.ledger_image.post.entries),
767
+ ...Object.keys(J.items),
768
+ ]);
769
+ const seedRoots = new Set([
770
+ ...Object.keys(J.ledger_image.pre.roots), ...Object.keys(J.ledger_image.post.roots),
771
+ ]);
772
+ for (;;) {
773
+ let grew = false;
774
+ for (const n of [...seedEntries]) {
775
+ for (const rk of L.entries[n]?.requested_by ?? []) if (!seedRoots.has(rk)) { seedRoots.add(rk); grew = true; }
776
+ }
777
+ for (const rk of [...seedRoots]) {
778
+ for (const [n, e] of Object.entries(L.entries)) {
779
+ if (e.requested_by.includes(rk) && !seedEntries.has(n)) { seedEntries.add(n); grew = true; }
780
+ }
781
+ }
782
+ if (!grew) break;
783
+ }
784
+ const ce = [...seedEntries].sort();
785
+ const cr = [...seedRoots].sort();
786
+ const after = applyImageSide(L, J.ledger_image.post, {});
787
+ return {
788
+ closure_entries: ce,
789
+ closure_roots: cr,
790
+ post: projection(after, ce, cr),
791
+ pre: projection(L, ce, cr),
792
+ };
793
+ }
794
+
795
+ /**
796
+ * §5.10 ③–⑥ 的恢复与续做。
797
+ * 🔴 **恢复一律按「物理实况」定位,不按 `state` 断言** ——
798
+ * `state=planned` **不代表**「tx 还在原处」。
799
+ */
800
+ export function resumeRepair(target, opts = {}) {
801
+ const P0 = layout(target);
802
+ const I0 = readIntent(P0);
803
+ const P = layout(target, I0.generation);
804
+ const Q = quarPaths(P);
805
+ let I = I0;
806
+
807
+ // ── ③ 隔离四项(幂等)───────────────────────────────────────────────────
808
+ // 🔴 只在**隔离阶段**做。`state` 一旦进到 `child_registered` 及以后,child 会
809
+ // 合法地把 `target/<name>` 重新装回来 —— 那时再跑一遍 ③ 会把它误判成
810
+ // 「原处与隔离位置同时存在」。`state × child` 的合法组合表已经保证了
811
+ // `child_registered` 蕴含「④ 已完成」,所以这里用 state 分阶段是成立的,
812
+ // 与「恢复按物理实况定位」不冲突(阶段内部仍然全靠实测)。
813
+ if (I.state !== 'planned' && I.state !== 'isolated') {
814
+ assertSameTransaction(Q, I);
815
+ return driveChild(target, P, Q, I, opts);
816
+ }
817
+ mkdirChainFsync(Q.targets);
818
+ fp('repair:step3:pre-isolate-tx', {});
819
+ isolateOne('tx', P.tx, Q.tx, () => txDigest(P.tx), () => txDigest(Q.tx), I.isolate.tx.fingerprint);
820
+ fp('repair:step3:post-isolate-tx', {});
821
+ isolateOne('journal', P.journal, Q.journal,
822
+ () => sha256(readFileSync(P.journal)), () => sha256(readFileSync(Q.journal)), I.isolate.journal.digest);
823
+ fp('repair:step3:post-isolate-journal', {});
824
+ for (const [n, t] of Object.entries(I.isolate.targets)) {
825
+ isolateOne(`target:${n}`, join(target, n), join(Q.targets, n),
826
+ () => treeDigest(join(target, n)), () => treeDigest(join(Q.targets, n)), t.observed);
827
+ }
828
+ fp('repair:step3:post-isolate-target', {});
829
+ clearLedgerTransaction(P, I);
830
+ fp('repair:step3:post-clear-ledger-transaction', {});
831
+
832
+ // ── ④ 重验四项全部到位 → state=isolated(同次补入 cur / child_op / restore_from)─
833
+ assertSameTransaction(Q, I);
834
+ if (I.state === 'planned') {
835
+ const items = {};
836
+ for (const [name, p] of Object.entries(I.plan.items)) {
837
+ const cur = measureCur(target, name, p);
838
+ const childOp = deriveChildOp(cur, p.target);
839
+ const next = { ...p, child_op: childOp, cur };
840
+ if (childOp === 'swap' || childOp === 'install-new') {
841
+ next.restore_from = pickRestoreFrom(P, Q, I, name, p, opts);
842
+ }
843
+ items[name] = next;
844
+ }
845
+ I = { ...I, plan: { ...I.plan, items }, state: 'isolated' };
846
+ writeIntent(P, I);
847
+ fp('repair:step4:post-isolated', {});
848
+ } else if (I.state === 'isolated') {
849
+ // 🔴 `planned` / `isolated`,**创建 child 之前**:重测 cur,与已持久化的值必须一致
850
+ for (const [name, p] of Object.entries(I.plan.items)) {
851
+ if (measureCur(target, name, p) !== p.cur) bad(`repair:${name} 的 cur 重测与已持久化的值不一致`);
852
+ }
853
+ }
854
+
855
+ // ── ⑤/⑥ child ────────────────────────────────────────────────────────────
856
+ return driveChild(target, P, Q, I, opts);
857
+ }
858
+
859
+ function isolateOne(what, orig, quar, digestOrig, digestQuar, expect) {
860
+ const a = existsSync(orig), b = existsSync(quar);
861
+ if (a && b) bad(`repair:${what} 在原处与隔离位置**同时存在**`);
862
+ // 🔴 §5.10 观测表:**两边都在 / 两边都不在 / 任一处存在但不落入记录的等价类** 一律 corrupt。
863
+ // `target:*` 也没有例外 —— 它是 beginRepair 已经观测到并写进 intent 的树,
864
+ // 两边都没有就是证据已经丢了(Codex 第二轮 #7)。
865
+ if (!a && !b) bad(`repair:${what} 在原处与隔离位置**都不存在**`);
866
+ if (b) {
867
+ if (digestQuar() !== expect) bad(`repair:${what} 在隔离位置但不落入记录的等价类`);
868
+ return; // 已完成,跳过
869
+ }
870
+ if (digestOrig() !== expect) bad(`repair:${what} 在原位置但不落入记录的等价类`);
871
+ mkdirChainFsync(join(quar, '..'));
872
+ renameDirFsync(orig, quar);
873
+ }
874
+
875
+ function clearLedgerTransaction(P, I) {
876
+ const L = readLedger(P.ledger);
877
+ if (L.transaction === null) return; // 已完成,跳过
878
+ const d = sha256(Buffer.from(stringify(L.transaction), 'utf8'));
879
+ if (d !== I.isolate.ledger_transaction.digest) {
880
+ bad('repair:ledger.transaction 既不匹配记录的摘要、也不是 null(有第三方改过账本)');
881
+ }
882
+ // 🔴 只改这一个键,用 §11 的 patch 语义
883
+ writeLedger(P.ledger, { ...L, transaction: null });
884
+ }
885
+
886
+ /**
887
+ * 🔴 同事务绑定:分别核验摘要**不够**,还必须验证四项属于**同一个事务**。
888
+ * 并且每次进来都要**重验隔离证据的持续身份** —— 只在隔离那一刻验过一次,
889
+ * 之后 child 阶段证据被改写就再也发现不了(Codex 第二轮 #10)。
890
+ */
891
+ function assertSameTransaction(Q, I) {
892
+ if (!existsSync(Q.tx)) bad('repair:tx 不在隔离位置');
893
+ if (!existsSync(Q.journal)) bad('repair:journal 不在隔离位置');
894
+ if (txDigest(Q.tx) !== I.isolate.tx.fingerprint) bad('repair:隔离 tx 的 geoly-tx-v1 指纹已变');
895
+ if (sha256(readFileSync(Q.journal)) !== I.isolate.journal.digest) bad('repair:隔离 journal 的摘要已变');
896
+ for (const [n, t] of Object.entries(I.isolate.targets)) {
897
+ const q = join(Q.targets, n);
898
+ if (!existsSync(q)) bad(`repair:隔离的 target ${n} 不在隔离位置`);
899
+ if (safeDigest(q) !== t.observed) bad(`repair:隔离的 target ${n} 观测指纹已变`);
900
+ }
901
+ const j = readJsonStrict(Q.journal, 'quarantined journal');
902
+ if (j.generation !== I.generation) bad('repair:隔离 journal 的 generation 与 intent 不一致');
903
+ if (j.tx_dir !== I.isolate.tx.dir) bad('repair:隔离 journal 的 tx_dir 与实际隔离的 tx 不一致');
904
+ }
905
+
906
+ /** 🔴 `cur` 的取值域是**封闭的四个**,不是「任意 digest」 */
907
+ function measureCur(target, name, p) {
908
+ const dir = join(target, name);
909
+ if (!existsSync(dir)) return 'absent';
910
+ let d;
911
+ try { d = treeDigest(dir); } catch { return 'other'; }
912
+ if (p.old_digest && d === p.old_digest) return 'old';
913
+ if (p.target.present && d === p.target.digest) return 'target';
914
+ return 'other';
915
+ }
916
+
917
+ /** 「当前实况 × 目标断言」—— 唯一能决定动作的两个量 */
918
+ function deriveChildOp(cur, tgt) {
919
+ if (cur === 'other') bad('repair:cur=other(既非旧树也非目标树)→ corrupt 停机');
920
+ if (cur === 'absent') return tgt.present ? 'install-new' : 'logical-only';
921
+ if (!tgt.present) return 'retire-only';
922
+ if (cur === 'target') return 'logical-only';
923
+ return 'swap';
924
+ }
925
+
926
+ /**
927
+ * `restore_from` 的定位与验法(§5.10)。
928
+ * 🔴 `quarantine-tx` 是**只读恢复介质**:**无跟随复制**到 child 的 `stage/`,
929
+ * 禁止 rename、禁止 hardlink、禁止以任何方式修改 quarantine 内容。
930
+ */
931
+ function pickRestoreFrom(P, Q, I, name, p, opts) {
932
+ const want = p.target.digest;
933
+ // ① quarantine 里那棵被隔离的 target 树。
934
+ // 🔴 先匹配 intent 里记录的**观测指纹**,再要求它等于目标摘要 —— 两道都要过。
935
+ const qt = join(Q.targets, name);
936
+ const observedOk = I.isolate.targets[name] !== undefined
937
+ && safeDigest(qt) === I.isolate.targets[name].observed;
938
+ if (observedOk && safeDigest(qt) === want) {
939
+ return { artifact: opts.artifactFor?.(name) ?? 'unknown', source: 'quarantine', tree_digest: want };
940
+ }
941
+ // ② 隔离 tx 的候选槽(两步制:先由 journal 状态枚举候选,再用实测摘要定生死)
942
+ for (const slot of candidateSlots(Q, I, name)) {
943
+ const dir = join(Q.tx, slot, name);
944
+ if (existsSync(dir) && safeDigest(dir) === want) {
945
+ return { artifact: opts.artifactFor?.(name) ?? 'unknown', slot, source: 'quarantine-tx', tree_digest: want };
946
+ }
947
+ }
948
+ // ③ attic 的某一代
949
+ if (existsSync(P.atticDir)) {
950
+ for (const g of readdirSync(P.atticDir).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a)) {
951
+ const mp = join(P.atticDir, String(g), 'manifest.json');
952
+ if (!existsSync(mp)) continue;
953
+ let M;
954
+ try { M = validateManifest(readJsonStrict(mp, 'attic-manifest')); } catch { continue; }
955
+ const mi = M.items[name];
956
+ if (!mi || !mi.tar || mi.old_digest !== want) continue;
957
+ const tar = join(P.atticDir, String(g), mi.tar);
958
+ try { verifyArchive(tar, want, P.state); } catch { continue; }
959
+ return { artifact: opts.artifactFor?.(name) ?? 'unknown', source: `attic/${g}`, tree_digest: want };
960
+ }
961
+ }
962
+ // ④ registry —— 🔴 这是 `--reinstall`「重新解析安装」的**主路径**。
963
+ // 下载 / 验签 / 资产 sha256 / 解包重算属于 §02-6 的完整验证链,不在内核范围,
964
+ // 由调用方注入 `resolver(name) -> { artifact, dir }`(dir 已过完整验证链)。
965
+ if (opts.resolver) {
966
+ const r = opts.resolver(name);
967
+ if (safeDigest(r.dir) !== want) bad(`repair:resolver 给的树摘要不符(${name})`);
968
+ return { artifact: r.artifact, source: 'registry', tree_digest: want };
969
+ }
970
+ bad(`repair:${name} 找不到可用的 restore_from(quarantine / quarantine-tx / attic 都没有摘要 == ${want} 的介质,`
971
+ + '且未注入 registry resolver)—— 枚举不出完整计划即拒绝自动执行,转人工');
972
+ return null;
973
+ }
974
+
975
+ /**
976
+ * §5.10 候选槽表。🔴 **两步制**:这里只**枚举候选**,实测摘要在调用点定生死。
977
+ */
978
+ function candidateSlots(Q, I, name) {
979
+ const j = readJsonStrict(Q.journal, 'quarantined journal');
980
+ const it = j.items?.[name];
981
+ if (!it) return [];
982
+ if (j.direction === 'rollback') {
983
+ const r = j.rollback?.items?.[name];
984
+ if (!r) return [];
985
+ const k = `${r.entry_class}|${r.rstate}`;
986
+ return ({
987
+ 'as-retired|pending': ['retired'],
988
+ 'as-retired-cleaned|pending': ['retired'],
989
+ 'as-swapped|pending': ['retired'],
990
+ 'as-swapped|t_parked': ['stage', 'retired'],
991
+ 'as-swapped-cleaned|pending': ['retired'],
992
+ 'as-swapped-cleaned|t_parked': ['stage', 'retired'],
993
+ })[k] ?? [];
994
+ }
995
+ const cleanup = it.cleanup ?? '-';
996
+ const k = `${it.op}|${it.state}|${cleanup}`;
997
+ return ({
998
+ 'swap|planned|-': ['stage'],
999
+ 'swap|retired|-': ['stage', 'retired'], // 🔴 两个都候选
1000
+ 'swap|swapped|-': ['retired'],
1001
+ 'swap|verified|-': ['retired'],
1002
+ 'swap|done|-': ['retired'],
1003
+ 'swap|done|tar_durable': ['retired'],
1004
+ 'install-new|planned|-': ['stage'],
1005
+ 'retire-only|retired|-': ['retired'],
1006
+ 'retire-only|verified|-': ['retired'],
1007
+ 'retire-only|done|-': ['retired'],
1008
+ 'retire-only|done|tar_durable': ['retired'],
1009
+ })[k] ?? [];
1010
+ }
1011
+
1012
+ /**
1013
+ * child 的子状态机 + `child_registered` 的四观测量矩阵。
1014
+ * 🔴 `child_done` / `done` 的三项前置断言**优先于整张矩阵判定**。
1015
+ */
1016
+ function driveChild(target, P, Q, I, opts) {
1017
+ const P0 = layout(target);
1018
+
1019
+ if (I.state === 'child_done' || I.state === 'done') {
1020
+ assertDonePreconditions(target, P0, I);
1021
+ if (I.state === 'child_done') {
1022
+ I = { ...I, state: 'done' };
1023
+ writeIntent(P, I);
1024
+ fp('repair:step6:post-state-done', {});
1025
+ }
1026
+ rmtreeFsync(P0.repairIntent);
1027
+ fp('repair:step6:post-intent-removed', {});
1028
+ return { repair: 'done', generation: I.generation };
1029
+ }
1030
+
1031
+ if (I.state === 'isolated') {
1032
+ // 🔴 此时**磁盘上不得有任何 child 残留**
1033
+ const L = readLedger(P0.ledger);
1034
+ if (L.transaction !== null) bad('repair:state=isolated 而 ledger.transaction 非 null');
1035
+ // 🔴 先过 2c 的阈值归档,再登记 child
1036
+ maybeArchiveAudit(P0, { maxEntries: opts.auditMaxEntries ?? DEFAULT_AUDIT_MAX_ENTRIES });
1037
+ const childGen = nextGeneration(P0);
1038
+ I = { ...I, child: { committed: false, generation: childGen, tx_dir: `tx-${childGen}` }, state: 'child_registered' };
1039
+ writeIntent(P, I);
1040
+ fp('repair:step5:post-child-register', {});
1041
+ }
1042
+
1043
+ // state = child_registered
1044
+ const guarded = childGuards(target, P0, I);
1045
+ fp('repair:step5:pre-child-create', {});
1046
+ const action = childMatrix(guarded);
1047
+ return applyChildAction(target, P, Q, I, guarded, action, opts);
1048
+ }
1049
+
1050
+ function assertDonePreconditions(target, P0, I) {
1051
+ if (!I.child || I.child.committed !== true) bad('repair:进入 child_done/done 必须 child.committed == true');
1052
+ const CP = layout(target, I.child.generation);
1053
+ if (existsSync(CP.tx)) bad('repair:进入 child_done/done 时不得有 child tx');
1054
+ if (existsSync(CP.journal)) bad('repair:进入 child_done/done 时不得有 child journal');
1055
+ const L = readLedger(P0.ledger);
1056
+ if (L.transaction !== null) bad('repair:进入 child_done/done 时 ledger.transaction 必须是 null');
1057
+ }
1058
+
1059
+ /** **前置守卫**(任一命中即 corrupt;`1` 转人工、不嵌套 repair)*/
1060
+ function childGuards(target, P0, I) {
1061
+ const cg = I.child.generation;
1062
+ const CP = layout(target, cg);
1063
+ // 0 · tx 属于他代
1064
+ for (const n of readdirSync(P0.state)) {
1065
+ const m = /^tx-(\d+)$/.exec(n);
1066
+ if (m && Number(m[1]) !== cg) bad(`repair 守卫 0:存在他代 tx(${n})`);
1067
+ }
1068
+ // 0″ · 存在任何不属于 child.generation 的 journal 残留(.tmp 已在 recover 入口扫掉)
1069
+ for (const g of listJournalGenerations(P0.journalDir)) {
1070
+ if (g !== cg) bad(`repair 守卫 0″:存在他代 journal 残留(${g}.json)`);
1071
+ }
1072
+ const L = readLedger(P0.ledger);
1073
+ // 0′ · transaction 属于他代
1074
+ if (L.transaction !== null && L.transaction.generation !== cg) {
1075
+ bad('repair 守卫 0′:ledger.transaction 属于他代');
1076
+ }
1077
+ // 1 · journal 为 corrupt
1078
+ let journal = '无';
1079
+ let J = null;
1080
+ if (existsSync(CP.journal)) {
1081
+ J = readJournal(CP.journal); // CRC/schema 失败即 corrupt 停机(守卫 1)
1082
+ journal = J.phase;
1083
+ }
1084
+ return {
1085
+ cg, CP, J,
1086
+ committed: I.child.committed,
1087
+ journal,
1088
+ tx: existsSync(CP.tx) ? '本代' : '无',
1089
+ transaction: L.transaction === null ? 'null' : '本代',
1090
+ };
1091
+ }
1092
+
1093
+ /** 矩阵(守卫全过之后,逐行唯一匹配);未列组合 fail-closed */
1094
+ function childMatrix(o) {
1095
+ const k = `${o.committed}|${o.journal}|${o.tx}|${o.transaction}`;
1096
+ const M = {
1097
+ 'false|无|无|null': 'create-tx', // 2
1098
+ 'false|无|无|本代': 'half-commit-rebind', // 3
1099
+ 'false|无|本代|null': 'precommit-rebind', // 4
1100
+ 'false|无|本代|本代': 'precommit-rebind', // 5
1101
+ 'false|prepared|本代|null': 'set-committed', // 6
1102
+ 'false|prepared|本代|本代': 'set-committed',
1103
+ 'false|cleanup_pending|本代|null': 'set-committed',
1104
+ 'false|cleanup_pending|本代|本代': 'set-committed',
1105
+ 'true|prepared|本代|null': 'resume-child', // 8
1106
+ 'true|prepared|本代|本代': 'resume-child',
1107
+ 'true|cleanup_pending|本代|null': 'resume-cleanup', // 9
1108
+ 'true|cleanup_pending|本代|本代': 'resume-cleanup',
1109
+ // 10 ✅ 正常可达(tx 已删、completed 未写)—— v28 在这里判 corrupt,是误杀
1110
+ 'true|cleanup_pending|无|null': 'finish-completed',
1111
+ 'true|cleanup_pending|无|本代': 'finish-completed',
1112
+ 'true|completed|无|本代': 'clear-then-final', // 12
1113
+ 'true|completed|无|null': 'drop-journal-final', // 13
1114
+ 'true|无|无|null': 'final-verify', // 15
1115
+ };
1116
+ const a = M[k];
1117
+ if (a) return a;
1118
+ // 7 / 11 / 14 / 16 与其余一切
1119
+ bad(`repair child 矩阵:(committed,journal,tx,transaction)=(${k}) → fail-closed`);
1120
+ return null;
1121
+ }
1122
+
1123
+ function applyChildAction(target, P, Q, I, g, action, opts) {
1124
+ const P0 = layout(target);
1125
+ const CP = g.CP;
1126
+ switch (action) {
1127
+ case 'half-commit-rebind':
1128
+ case 'precommit-rebind': {
1129
+ // 🔴 先按双文件规则清掉 transaction,再**重绑一个新 generation**(水位只增、旧号作废)
1130
+ const L = readLedger(P0.ledger);
1131
+ if (L.transaction !== null) writeLedger(P0.ledger, { ...L, transaction: null });
1132
+ if (existsSync(CP.tx)) rmtreeFsync(CP.tx);
1133
+ const ng = nextGeneration(P0);
1134
+ const I2 = { ...I, child: { committed: false, generation: ng, tx_dir: `tx-${ng}` } };
1135
+ writeIntent(P, I2);
1136
+ return resumeRepair(target, opts);
1137
+ }
1138
+ case 'set-committed': {
1139
+ // child journal 已提交而父 intent 还没来得及写 committed:true → **先补写 true**
1140
+ const I2 = { ...I, child: { ...I.child, committed: true } };
1141
+ writeIntent(P, I2);
1142
+ return resumeRepair(target, opts);
1143
+ }
1144
+ case 'create-tx': {
1145
+ createChildTransaction(target, P, Q, I, opts);
1146
+ return resumeRepair(target, opts);
1147
+ }
1148
+ case 'resume-child': {
1149
+ applyItems(target, g.J, CP);
1150
+ verifyAndCommit(target, g.J, CP);
1151
+ runCleanup(target, g.J, CP, opts);
1152
+ return resumeRepair(target, opts);
1153
+ }
1154
+ case 'resume-cleanup': {
1155
+ runCleanup(target, g.J, CP, opts);
1156
+ return resumeRepair(target, opts);
1157
+ }
1158
+ case 'finish-completed': {
1159
+ const L = readLedger(P0.ledger);
1160
+ if (L.transaction !== null) writeLedger(P0.ledger, { ...L, transaction: null });
1161
+ g.J.phase = 'completed';
1162
+ writeJournal(CP.journal, g.J);
1163
+ return resumeRepair(target, opts);
1164
+ }
1165
+ case 'clear-then-final': {
1166
+ const L = readLedger(P0.ledger);
1167
+ writeLedger(P0.ledger, { ...L, transaction: null });
1168
+ return resumeRepair(target, opts);
1169
+ }
1170
+ case 'drop-journal-final': {
1171
+ rmtreeFsync(CP.journal);
1172
+ return resumeRepair(target, opts);
1173
+ }
1174
+ case 'final-verify': {
1175
+ finalVerify(target, P, I);
1176
+ const I2 = { ...I, state: 'child_done' };
1177
+ writeIntent(P, I2);
1178
+ fp('repair:step6:post-child-done', {});
1179
+ return driveChild(target, P, Q, I2, opts);
1180
+ }
1181
+ default:
1182
+ return bad(`repair:未知动作 ${action}`);
1183
+ }
1184
+ }
1185
+
1186
+ /** 🔴 重验最终 ledger 与目标树是否符合 `plan.repair_ledger_image.post` */
1187
+ function finalVerify(target, P, I) {
1188
+ const rli = I.plan.repair_ledger_image;
1189
+ const L = readLedger(P.ledger);
1190
+ const proj = projection(L, rli.closure_entries, rli.closure_roots);
1191
+ if (stringify(proj) !== stringify(rli.post)) {
1192
+ bad('repair 最终重验:账本在 closure 上的投影与 repair_ledger_image.post 不等');
1193
+ }
1194
+ for (const [name, p] of Object.entries(I.plan.items)) {
1195
+ const dir = join(target, name);
1196
+ if (p.target.present) {
1197
+ if (!existsSync(dir)) bad(`repair 最终重验:${name} 应存在却缺席`);
1198
+ if (treeDigest(dir) !== p.target.digest) bad(`repair 最终重验:${name} 摘要与目标断言不符`);
1199
+ } else if (existsSync(dir)) bad(`repair 最终重验:${name} 应缺席却存在`);
1200
+ }
1201
+ }
1202
+
1203
+ /**
1204
+ * 建 child 事务。
1205
+ * 🔴 **repair-child 是「`old_digest` 取实测值」这条通则的例外**:写进 child journal 的
1206
+ * 是 `expected_old = plan.items[<name>].old_digest`(来自原 journal 的已验证值),
1207
+ * 不是此刻的实测值 —— 否则会**把一棵外部的树当成旧树退役掉**。
1208
+ */
1209
+ function createChildTransaction(target, P, Q, I, opts) {
1210
+ const P0 = layout(target);
1211
+ const cg = I.child.generation;
1212
+ const CP = layout(target, cg);
1213
+ const L = readLedger(P0.ledger);
1214
+
1215
+ const items = {};
1216
+ const sources = {};
1217
+ for (const [name, p] of Object.entries(I.plan.items)) {
1218
+ const op = p.child_op;
1219
+ if (op === 'logical-only') continue; // 🔴 不建 stage / retired / attic item
1220
+ if (op === 'retire-only') {
1221
+ // 写 child prepared 之前:实测值必须等于 expected_old
1222
+ const got = safeDigest(join(target, name));
1223
+ if (got !== p.old_digest) bad(`repair child:${name} 实测 ${got} != expected_old ${p.old_digest}`);
1224
+ items[name] = { op: 'retire-only', had_old: true, state: 'planned', old_digest: p.old_digest };
1225
+ continue;
1226
+ }
1227
+ const src = materializeRestore(P, Q, I, name, p, CP, opts);
1228
+ if (op === 'install-new') {
1229
+ items[name] = { op: 'install-new', had_old: false, state: 'planned', new_digest: p.target.digest };
1230
+ } else {
1231
+ const got = safeDigest(join(target, name));
1232
+ if (got !== p.old_digest) bad(`repair child:${name} 实测 ${got} != expected_old ${p.old_digest}`);
1233
+ items[name] = {
1234
+ op: 'swap', had_old: true, state: 'planned',
1235
+ new_digest: p.target.digest, old_digest: p.old_digest,
1236
+ };
1237
+ }
1238
+ sources[name] = src;
1239
+ }
1240
+
1241
+ // child 的 `ledger_image.pre` **必须等于「隔离完成之后」的实际账本投影**
1242
+ const rli = I.plan.repair_ledger_image;
1243
+ const materialize = (side) => {
1244
+ const entries = {}, roots = {};
1245
+ for (const k of rli.closure_entries) entries[k] = Object.hasOwn(side.entries, k) ? side.entries[k] : null;
1246
+ for (const k of rli.closure_roots) roots[k] = Object.hasOwn(side.roots, k) ? side.roots[k] : null;
1247
+ return { entries, roots };
1248
+ };
1249
+ const preProj = projection(L, rli.closure_entries, rli.closure_roots);
1250
+ if (stringify(preProj) !== stringify(rli.pre)) bad('repair child:隔离后的账本投影与 repair_ledger_image.pre 不等');
1251
+ const image = {
1252
+ ledger_existed: true,
1253
+ // 🔴 两个非 closure 字段另有来源:`last_applied_generation` 与整张 `frozen_attic`
1254
+ // 一律从「隔离后的账本」取值。
1255
+ post: { ...materialize(rli.post), last_applied_generation: cg },
1256
+ pre: { ...materialize(rli.pre), last_applied_generation: L.last_applied_generation },
1257
+ };
1258
+ if (L.frozen_attic) { image.pre.frozen_attic = L.frozen_attic; image.post.frozen_attic = L.frozen_attic; }
1259
+
1260
+ const plan = {
1261
+ generation: cg,
1262
+ items,
1263
+ ledger_image: image,
1264
+ sources,
1265
+ tx_dir: `tx-${cg}`,
1266
+ };
1267
+ stageTrees(CP, plan);
1268
+ // 🔴 出处决定要不要过 floor 屏障(Codex 第二轮 #9):
1269
+ // 只有 quarantine / quarantine-tx / attic 这类**本机既有证据**才允许 floor: null;
1270
+ // 只要有任何一项来自 registry,就必须由调用方显式给 floor —— 否则拒绝,
1271
+ // 不得用 `opts.floor ?? null` 把检查悄悄跳掉。
1272
+ const fromRegistry = Object.values(I.plan.items)
1273
+ .some((p) => p.restore_from?.source === 'registry');
1274
+ if (fromRegistry && !opts.floor) {
1275
+ bad('repair child 从 registry 重新解析安装,必须显式给 opts.floor(提交点前要复验 trust floor)');
1276
+ }
1277
+ const J = commitPoint(CP, plan, { floor: fromRegistry ? opts.floor : null, repairId: I.repair_id });
1278
+ // child journal 成功提交(phase = prepared 落盘)之后,才把 committed 翻成 true
1279
+ writeIntent(P, { ...I, child: { ...I.child, committed: true } });
1280
+ applyItems(target, J, CP);
1281
+ verifyAndCommit(target, J, CP);
1282
+ runCleanup(target, J, CP, opts);
1283
+ }
1284
+
1285
+ /** 把 `restore_from` 指定的介质**无跟随复制**到 child 的 unpack 区,返回该目录 */
1286
+ function materializeRestore(P, Q, I, name, p, CP, opts) {
1287
+ const rf = p.restore_from;
1288
+ const want = p.target.digest;
1289
+ const dest = join(CP.unpack, name);
1290
+ if (existsSync(dest)) rmtreeFsync(dest);
1291
+ mkdirChainFsync(join(dest, '..'));
1292
+
1293
+ if (rf.source === 'quarantine' || rf.source === 'quarantine-tx') {
1294
+ const src = rf.source === 'quarantine' ? join(Q.targets, name) : join(Q.tx, rf.slot, name);
1295
+ // ①a 复制前:该候选 slot 的实测摘要 == target.digest
1296
+ if (safeDigest(src) !== want) bad(`repair:${rf.source} 介质摘要不符(①a)`);
1297
+ // ①b 复制前:隔离 tx 的 geoly-tx-v1 指纹 == isolate.tx.fingerprint
1298
+ if (txDigest(Q.tx) !== I.isolate.tx.fingerprint) bad('repair:隔离 tx 指纹在复制前已不符(①b)');
1299
+ copyReadOnly(src, dest);
1300
+ // ② 复制后:**再验一次**隔离 tx 的指纹(后验能发现持续性篡改)
1301
+ if (txDigest(Q.tx) !== I.isolate.tx.fingerprint) bad('repair:隔离 tx 指纹在复制后不符(②)');
1302
+ // ③ stage 完成后:除摘要外**重跑目标树的完整结构校验**
1303
+ const m = strictlyMatches(dest, want);
1304
+ if (!m.ok) bad(`repair:复制出来的树未通过结构校验(③):${m.why}`);
1305
+ return dest;
1306
+ }
1307
+ const mAttic = /^attic\/(\d+)$/.exec(rf.source);
1308
+ if (mAttic) {
1309
+ const g = mAttic[1];
1310
+ restoreArchive(join(P.atticDir, g, `${name}.tar`), dest, want);
1311
+ const m = strictlyMatches(dest, want);
1312
+ if (!m.ok) bad(`repair:attic 还原出来的树未通过结构校验:${m.why}`);
1313
+ return dest;
1314
+ }
1315
+ if (rf.source === 'registry') {
1316
+ if (!opts.resolver) bad(`repair:source=registry 需要注入 resolver(不在内核范围)`);
1317
+ const r = opts.resolver(name);
1318
+ // 🔴 调用方已过完整验证链;这里再验一次落位后的摘要与结构,不因「上游说验过了」而放行
1319
+ copyReadOnly(r.dir, dest);
1320
+ const m = strictlyMatches(dest, want);
1321
+ if (!m.ok) bad(`repair:registry 还原出来的树未通过结构校验:${m.why}`);
1322
+ return dest;
1323
+ }
1324
+ return bad(`repair:未知 restore_from.source ${rf.source}`);
1325
+ }
1326
+
1327
+ /** 🔴 **无跟随复制**。禁止 rename、禁止 hardlink、禁止以任何方式修改 quarantine 内容。 */
1328
+ function copyReadOnly(src, dest) {
1329
+ mkdirChainFsync(dest);
1330
+ for (const name of readdirSync(src).sort()) {
1331
+ const abs = join(src, name);
1332
+ const st = require$lstat(abs);
1333
+ if (st.isSymbolicLink()) bad(`repair 复制:拒绝 symlink ${abs}`);
1334
+ if (st.isDirectory()) { copyReadOnly(abs, join(dest, name)); continue; }
1335
+ if (!st.isFile()) bad(`repair 复制:拒绝非普通文件 ${abs}`);
1336
+ writeAtomic(join(dest, name), readFileSync(abs));
1337
+ const mode = st.mode & 0o777;
1338
+ if (mode !== 0o644) require$chmod(join(dest, name), mode);
1339
+ }
1340
+ }
1341
+
1342
+ // 这两个只在 copyReadOnly 里用到,单独引出来是为了让「除此之外全部走 atomic-fs」一眼可查
1343
+ import { lstatSync as require$lstat, chmodSync as require$chmod } from 'node:fs';
1344
+
1345
+ export { REVERSE_OP };