knodin 0.13.0 → 0.13.1

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.
@@ -1,12 +1,65 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { acquireRepairLease } from "../repair-lease.js";
5
+ import { assertStorageCapacity } from "../storage-budget.js";
6
+ import { assertManagedStorageAllocation, resolveStoragePolicy } from "../storage-management.js";
7
+ import { assertStoragePolicyWritable } from "../storage-policy-contract.js";
4
8
  import { isSealedDatabase } from "./seal.js";
5
9
  import { Database } from "./sqlite.js";
6
10
  import { resolveDbPath, resolveStateDir } from "./state-paths.js";
7
11
  const PROMOTION_SCHEMA_VERSION = 1;
8
12
  const CANDIDATES_DIRECTORY = "candidates";
9
13
  const PROMOTION_MARKER = "promotion.json";
14
+ const CANDIDATE_OWNER = "candidate-owner.json";
15
+ function readCandidateOwner(directory, directoryStat) {
16
+ const target = path.join(directory, CANDIDATE_OWNER);
17
+ const stat = fs.lstatSync(target);
18
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 4096)
19
+ throw new Error("candidate owner marker is missing, unsafe or oversized");
20
+ const descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
21
+ try {
22
+ const opened = fs.fstatSync(descriptor);
23
+ if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size)
24
+ throw new Error("candidate owner changed during read");
25
+ const buffer = Buffer.alloc(stat.size + 1);
26
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
27
+ const final = fs.fstatSync(descriptor);
28
+ if (bytes !== stat.size || final.size !== stat.size || final.mtimeMs !== stat.mtimeMs)
29
+ throw new Error("candidate owner changed during read");
30
+ const owner = JSON.parse(buffer.subarray(0, bytes).toString("utf8"));
31
+ if (!owner ||
32
+ typeof owner !== "object" ||
33
+ Array.isArray(owner) ||
34
+ Object.keys(owner).length !== 6 ||
35
+ owner.schemaVersion !== 1 ||
36
+ !Number.isSafeInteger(owner.pid) ||
37
+ owner.pid <= 0 ||
38
+ typeof owner.nonce !== "string" ||
39
+ !/^[0-9a-f-]{36}$/.test(owner.nonce) ||
40
+ !Number.isSafeInteger(owner.directoryDevice) ||
41
+ !Number.isSafeInteger(owner.directoryInode) ||
42
+ owner.directoryDevice !== directoryStat.dev ||
43
+ owner.directoryInode !== directoryStat.ino ||
44
+ typeof owner.createdAt !== "string" ||
45
+ !Number.isFinite(Date.parse(owner.createdAt)))
46
+ throw new Error("candidate owner marker is invalid or belongs to a different directory");
47
+ return owner;
48
+ }
49
+ finally {
50
+ fs.closeSync(descriptor);
51
+ }
52
+ }
53
+ /** Only ESRCH proves absence. EPERM, reused/live PIDs and every uncertainty preserve. */
54
+ function candidateOwnerIsDead(owner) {
55
+ try {
56
+ process.kill(owner.pid, 0);
57
+ return false;
58
+ }
59
+ catch (error) {
60
+ return error.code === "ESRCH";
61
+ }
62
+ }
10
63
  function inside(parent, child) {
11
64
  const relative = path.relative(path.resolve(parent), path.resolve(child));
12
65
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
@@ -24,7 +77,7 @@ function assertPromotionRecord(repo, value) {
24
77
  !inside(stateDir, record.candidatePath) ||
25
78
  !inside(stateDir, record.backupPath) ||
26
79
  !record.phase ||
27
- !["prepared", "backed-up", "promoted"].includes(record.phase)) {
80
+ !["prepared", "backed-up", "promoted", "repair-needed"].includes(record.phase)) {
28
81
  throw new Error("invalid promotion recovery record");
29
82
  }
30
83
  return record;
@@ -84,35 +137,65 @@ export function listCandidates(repo) {
84
137
  const root = candidateRoot(resolvedRepo);
85
138
  let entries;
86
139
  try {
87
- entries = fs.readdirSync(root, { withFileTypes: true });
140
+ const state = resolveStateDir(resolvedRepo);
141
+ for (const target of [state, root]) {
142
+ const stat = fs.lstatSync(target);
143
+ if (!stat.isDirectory() || stat.isSymbolicLink())
144
+ return [];
145
+ }
146
+ if (path.dirname(fs.realpathSync(root)) !== fs.realpathSync(state))
147
+ return [];
148
+ entries = fs.opendirSync(root);
88
149
  }
89
150
  catch {
90
151
  return [];
91
152
  }
92
153
  const found = [];
93
- for (const entry of entries) {
94
- if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
95
- continue;
96
- const databasePath = path.join(root, entry.name, "db.sqlite");
97
- let modifiedMs;
98
- try {
99
- modifiedMs = fs.statSync(databasePath).mtimeMs;
100
- }
101
- catch {
102
- // A directory with no database is a half-created candidate, not a
103
- // resumable one.
104
- continue;
154
+ try {
155
+ for (let inspected = 0; inspected < 256; inspected++) {
156
+ const entry = entries.readSync();
157
+ if (!entry)
158
+ break;
159
+ if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
160
+ continue;
161
+ const databasePath = path.join(root, entry.name, "db.sqlite");
162
+ let modifiedMs;
163
+ try {
164
+ const candidate = {
165
+ schemaVersion: 1,
166
+ id: entry.name,
167
+ repo: resolvedRepo,
168
+ databasePath,
169
+ createdAt: new Date().toISOString(),
170
+ };
171
+ const bound = bindCandidateDatabase(resolvedRepo, candidate);
172
+ try {
173
+ bound.verify();
174
+ modifiedMs = fs.lstatSync(databasePath).mtimeMs;
175
+ }
176
+ finally {
177
+ bound.close();
178
+ }
179
+ }
180
+ catch {
181
+ // A directory with no database is a half-created candidate, not a
182
+ // resumable one.
183
+ continue;
184
+ }
185
+ found.push({
186
+ candidate: {
187
+ schemaVersion: 1,
188
+ id: entry.name,
189
+ repo: resolvedRepo,
190
+ databasePath,
191
+ createdAt: new Date(modifiedMs).toISOString(),
192
+ },
193
+ modifiedMs,
194
+ });
105
195
  }
106
- found.push({
107
- candidate: {
108
- schemaVersion: 1,
109
- id: entry.name,
110
- repo: resolvedRepo,
111
- databasePath,
112
- createdAt: new Date(modifiedMs).toISOString(),
113
- },
114
- modifiedMs,
115
- });
196
+ }
197
+ finally {
198
+ entries.closeSync();
116
199
  }
117
200
  // Sorted as its own statement rather than mid-chain: an in-place `sort`
118
201
  // inside an expression mutates the array being read, which is harmless for
@@ -121,12 +204,62 @@ export function listCandidates(repo) {
121
204
  found.sort((a, b) => b.modifiedMs - a.modifiedMs);
122
205
  return found.map((item) => item.candidate);
123
206
  }
124
- export function allocateCandidate(repo) {
207
+ export function allocateCandidate(repo, capacityDependencies) {
125
208
  const resolvedRepo = path.resolve(repo);
126
209
  const root = candidateRoot(resolvedRepo);
210
+ const storagePolicy = assertManagedStorageAllocation(resolvedRepo);
211
+ assertStorageCapacity({
212
+ stateDirectory: resolveStateDir(resolvedRepo),
213
+ databasePath: resolveDbPath(resolvedRepo),
214
+ operation: "candidate",
215
+ reserveBytes: storagePolicy?.minFreeBytes,
216
+ }, capacityDependencies);
217
+ try {
218
+ const stat = fs.lstatSync(root);
219
+ if (!stat.isDirectory() ||
220
+ stat.isSymbolicLink() ||
221
+ path.dirname(fs.realpathSync(root)) !== fs.realpathSync(resolveStateDir(resolvedRepo)))
222
+ throw new Error("candidate root must be a contained regular nonsymlink directory");
223
+ }
224
+ catch (error) {
225
+ if (error.code !== "ENOENT")
226
+ throw error;
227
+ }
127
228
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
229
+ const rootIdentity = fs.lstatSync(root);
230
+ const canonicalState = fs.realpathSync(resolveStateDir(resolvedRepo));
231
+ const verifyRoot = () => {
232
+ const current = fs.lstatSync(root);
233
+ if (!current.isDirectory() ||
234
+ current.isSymbolicLink() ||
235
+ current.dev !== rootIdentity.dev ||
236
+ current.ino !== rootIdentity.ino ||
237
+ path.dirname(fs.realpathSync(root)) !== canonicalState)
238
+ throw new Error("candidate root changed during allocation");
239
+ };
240
+ verifyRoot();
128
241
  const directory = fs.mkdtempSync(path.join(root, "candidate-"));
129
242
  fs.chmodSync(directory, 0o700);
243
+ const directoryStat = fs.lstatSync(directory);
244
+ verifyRoot();
245
+ if (!directoryStat.isDirectory() ||
246
+ directoryStat.isSymbolicLink() ||
247
+ path.dirname(fs.realpathSync(directory)) !== fs.realpathSync(root))
248
+ throw new Error("candidate directory escaped during allocation");
249
+ const owner = {
250
+ schemaVersion: 1,
251
+ pid: process.pid,
252
+ nonce: crypto.randomUUID(),
253
+ directoryDevice: directoryStat.dev,
254
+ directoryInode: directoryStat.ino,
255
+ createdAt: new Date().toISOString(),
256
+ };
257
+ verifyRoot();
258
+ fs.writeFileSync(path.join(directory, CANDIDATE_OWNER), `${JSON.stringify(owner)}\n`, {
259
+ flag: "wx",
260
+ mode: 0o600,
261
+ });
262
+ verifyRoot();
130
263
  return {
131
264
  schemaVersion: 1,
132
265
  id: path.basename(directory),
@@ -139,10 +272,110 @@ export function assertCandidate(repo, candidate) {
139
272
  const resolvedRepo = path.resolve(repo);
140
273
  if (candidate.schemaVersion !== 1 ||
141
274
  candidate.repo !== resolvedRepo ||
275
+ !/^candidate-[A-Za-z0-9_-]+$/.test(candidate.id) ||
276
+ path.dirname(candidate.databasePath) !== path.join(candidateRoot(resolvedRepo), candidate.id) ||
142
277
  !inside(candidateRoot(resolvedRepo), candidate.databasePath) ||
143
278
  path.basename(candidate.databasePath) !== "db.sqlite") {
144
279
  throw new Error("candidate does not belong to this repository");
145
280
  }
281
+ const directories = [
282
+ resolveStateDir(resolvedRepo),
283
+ candidateRoot(resolvedRepo),
284
+ path.dirname(candidate.databasePath),
285
+ ];
286
+ for (let index = 0; index < directories.length; index++) {
287
+ const directory = directories[index];
288
+ try {
289
+ const stat = fs.lstatSync(directory);
290
+ if (!stat.isDirectory() ||
291
+ stat.isSymbolicLink() ||
292
+ (index > 0 &&
293
+ path.dirname(fs.realpathSync(directory)) !== fs.realpathSync(directories[index - 1])))
294
+ throw new Error("unsafe candidate directory containment");
295
+ }
296
+ catch (error) {
297
+ if (error.code !== "ENOENT")
298
+ throw error;
299
+ }
300
+ }
301
+ }
302
+ /** Resume never assumes unknown or other live process ownership is abandoned. */
303
+ export function assertCandidateResumeOwner(candidate) {
304
+ assertCandidate(candidate.repo, candidate);
305
+ const directory = path.dirname(candidate.databasePath);
306
+ const owner = readCandidateOwner(directory, fs.lstatSync(directory));
307
+ if (owner.pid !== process.pid && !candidateOwnerIsDead(owner))
308
+ throw new Error("candidate owner is live or unknown");
309
+ }
310
+ /** Hold a no-follow descriptor and detect pathname substitution at SQLite boundaries.
311
+ * SQLite still opens by pathname; this is bounded race detection, not openat isolation.
312
+ */
313
+ export function bindCandidateDatabase(repo, candidate) {
314
+ assertCandidate(repo, candidate);
315
+ const directories = [
316
+ resolveStateDir(repo),
317
+ candidateRoot(repo),
318
+ path.dirname(candidate.databasePath),
319
+ ];
320
+ const identities = directories.map((directory) => fs.lstatSync(directory));
321
+ let stat;
322
+ try {
323
+ stat = fs.lstatSync(candidate.databasePath);
324
+ }
325
+ catch (error) {
326
+ if (error.code === "ENOENT")
327
+ throw new Error("candidate database is missing", { cause: error });
328
+ throw error;
329
+ }
330
+ if (!stat.isFile() || stat.isSymbolicLink())
331
+ throw new Error("unsafe candidate database");
332
+ const descriptor = fs.openSync(candidate.databasePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
333
+ try {
334
+ const verify = (target = candidate.databasePath) => {
335
+ if (target !== candidate.databasePath && target !== resolveDbPath(repo))
336
+ throw new Error("invalid candidate verification target");
337
+ assertCandidate(repo, candidate);
338
+ for (let index = 0; index < directories.length; index++) {
339
+ const current = fs.lstatSync(directories[index]);
340
+ if (current.dev !== identities[index].dev || current.ino !== identities[index].ino)
341
+ throw new Error("candidate directory changed during access");
342
+ }
343
+ const opened = fs.fstatSync(descriptor);
344
+ const current = fs.lstatSync(target);
345
+ if (!current.isFile() ||
346
+ current.isSymbolicLink() ||
347
+ opened.dev !== stat.dev ||
348
+ opened.ino !== stat.ino ||
349
+ current.dev !== stat.dev ||
350
+ current.ino !== stat.ino)
351
+ throw new Error("candidate database changed during access");
352
+ for (const suffix of ["-wal", "-shm"]) {
353
+ try {
354
+ const sidecar = fs.lstatSync(`${target}${suffix}`);
355
+ if (!sidecar.isFile() || sidecar.isSymbolicLink())
356
+ throw new Error("unsafe candidate sidecar");
357
+ }
358
+ catch (error) {
359
+ if (error.code !== "ENOENT")
360
+ throw error;
361
+ }
362
+ }
363
+ };
364
+ verify();
365
+ const header = Buffer.alloc(16);
366
+ const bytes = fs.readSync(descriptor, header, 0, 16, 0);
367
+ verify();
368
+ return {
369
+ descriptor,
370
+ header: header.subarray(0, bytes),
371
+ verify,
372
+ close: () => fs.closeSync(descriptor),
373
+ };
374
+ }
375
+ catch (error) {
376
+ fs.closeSync(descriptor);
377
+ throw error;
378
+ }
146
379
  }
147
380
  /** Restore the previous active graph after a process stopped between promotion renames. */
148
381
  export function recoverInterruptedPromotion(repo) {
@@ -150,6 +383,8 @@ export function recoverInterruptedPromotion(repo) {
150
383
  if (!fs.existsSync(marker))
151
384
  return "none";
152
385
  const record = assertPromotionRecord(repo, JSON.parse(fs.readFileSync(marker, "utf8")));
386
+ if (record.phase === "repair-needed")
387
+ throw new Error("promotion identity changed; preserve active, backup and candidate files and inspect promotion.json before repair");
153
388
  const activeExists = fs.existsSync(record.activePath);
154
389
  const backupExists = fs.existsSync(record.backupPath);
155
390
  let outcome;
@@ -193,6 +428,15 @@ function activeDatabaseIsSealed(activePath) {
193
428
  }
194
429
  /** Same-filesystem, marker-backed promotion. Caller must close and audit both databases first. */
195
430
  export function promoteCandidateFile(repo, candidate) {
431
+ const bound = bindCandidateDatabase(repo, candidate);
432
+ try {
433
+ return promoteBoundCandidateFile(repo, candidate, bound);
434
+ }
435
+ finally {
436
+ bound.close();
437
+ }
438
+ }
439
+ function promoteBoundCandidateFile(repo, candidate, bound) {
196
440
  assertCandidate(repo, candidate);
197
441
  const activePath = resolveDbPath(repo);
198
442
  const stateDir = resolveStateDir(repo);
@@ -225,12 +469,23 @@ export function promoteCandidateFile(repo, candidate) {
225
469
  backupPath,
226
470
  };
227
471
  atomicRecord(marker, record);
472
+ let candidateRenamed = false;
473
+ let activeBackedUp = false;
474
+ let previousActive;
228
475
  try {
229
- if (fs.existsSync(activePath))
476
+ if (fs.existsSync(activePath)) {
477
+ previousActive = fs.lstatSync(activePath);
478
+ if (!previousActive.isFile() || previousActive.isSymbolicLink())
479
+ throw new Error("unsafe active database preserved");
230
480
  fs.renameSync(activePath, backupPath);
481
+ activeBackedUp = true;
482
+ }
231
483
  record = { ...record, phase: "backed-up" };
232
484
  atomicRecord(marker, record);
485
+ bound.verify();
233
486
  fs.renameSync(candidate.databasePath, activePath);
487
+ candidateRenamed = true;
488
+ bound.verify(activePath);
234
489
  record = { ...record, phase: "promoted" };
235
490
  atomicRecord(marker, record);
236
491
  fsyncFile(activePath);
@@ -256,7 +511,40 @@ export function promoteCandidateFile(repo, candidate) {
256
511
  // which would move the just-promoted database back into the candidate
257
512
  // directory and restore the old graph over a promotion that succeeded.
258
513
  try {
259
- fs.rmSync(path.dirname(candidate.databasePath), { recursive: true, force: true });
514
+ bound.verify(activePath);
515
+ const directory = path.dirname(candidate.databasePath);
516
+ const owner = readCandidateOwner(directory, fs.lstatSync(directory));
517
+ if (owner.pid !== process.pid && !candidateOwnerIsDead(owner))
518
+ throw new Error("promoted residue owner live or unknown");
519
+ const contents = fs.opendirSync(directory);
520
+ const names = [];
521
+ try {
522
+ for (let count = 0; count < 3; count++) {
523
+ const entry = contents.readSync();
524
+ if (!entry)
525
+ break;
526
+ if (![CANDIDATE_OWNER, "status-audit-v1.json"].includes(entry.name))
527
+ throw new Error("unexpected promoted candidate residue preserved");
528
+ const stat = fs.lstatSync(path.join(directory, entry.name));
529
+ if (!stat.isFile() ||
530
+ stat.isSymbolicLink() ||
531
+ stat.size > (entry.name === CANDIDATE_OWNER ? 4096 : 1_048_576))
532
+ throw new Error("unsafe promoted residue preserved");
533
+ names.push(entry.name);
534
+ }
535
+ }
536
+ finally {
537
+ contents.closeSync();
538
+ }
539
+ for (const name of [
540
+ ...names.filter((name) => name !== CANDIDATE_OWNER),
541
+ ...names.filter((name) => name === CANDIDATE_OWNER),
542
+ ]) {
543
+ bound.verify(activePath);
544
+ fs.unlinkSync(path.join(directory, name));
545
+ }
546
+ bound.verify(activePath);
547
+ fs.rmdirSync(directory);
260
548
  }
261
549
  catch {
262
550
  // Residue only. `sweepAbandonedCandidates` collects it on the next run.
@@ -264,60 +552,330 @@ export function promoteCandidateFile(repo, candidate) {
264
552
  return fs.existsSync(backupPath) ? backupPath : null;
265
553
  }
266
554
  catch (error) {
267
- if (fs.existsSync(activePath) && fs.existsSync(backupPath)) {
268
- fs.mkdirSync(path.dirname(candidate.databasePath), { recursive: true, mode: 0o700 });
269
- fs.renameSync(activePath, candidate.databasePath);
555
+ try {
556
+ // A failed identity check is not authority to move an unknown inode.
557
+ // Preserve all evidence and block automatic recovery in that case.
558
+ bound.verify(candidateRenamed ? activePath : candidate.databasePath);
559
+ if (candidateRenamed) {
560
+ try {
561
+ fs.lstatSync(candidate.databasePath);
562
+ throw new Error("candidate rollback destination is occupied");
563
+ }
564
+ catch (failure) {
565
+ if (failure.code !== "ENOENT")
566
+ throw failure;
567
+ }
568
+ fs.renameSync(activePath, candidate.databasePath);
569
+ }
570
+ if (activeBackedUp && previousActive) {
571
+ const backup = fs.lstatSync(backupPath);
572
+ if (!backup.isFile() ||
573
+ backup.isSymbolicLink() ||
574
+ backup.dev !== previousActive.dev ||
575
+ backup.ino !== previousActive.ino)
576
+ throw new Error("backup identity changed during rollback");
577
+ try {
578
+ fs.lstatSync(activePath);
579
+ throw new Error("active rollback destination is occupied");
580
+ }
581
+ catch (failure) {
582
+ if (failure.code !== "ENOENT")
583
+ throw failure;
584
+ }
585
+ fs.renameSync(backupPath, activePath);
586
+ }
587
+ fs.rmSync(marker, { force: true });
588
+ fsyncDirectory(stateDir);
589
+ }
590
+ catch (rollbackError) {
591
+ atomicRecord(marker, { ...record, phase: "repair-needed" });
592
+ throw new Error("promotion rollback could not verify file identity; files preserved, inspect promotion.json before repair", { cause: rollbackError });
270
593
  }
271
- if (!fs.existsSync(activePath) && fs.existsSync(backupPath))
272
- fs.renameSync(backupPath, activePath);
273
- fs.rmSync(marker, { force: true });
274
- fsyncDirectory(stateDir);
275
594
  throw error;
276
595
  }
277
596
  }
278
- /**
279
- * Remove candidate directories that hold no database, and report how many.
280
- *
281
- * `listCandidates` deliberately answers "what could be resumed", so it skips a
282
- * directory with no `db.sqlite`. Cleanup used to run only over that list, which
283
- * meant the one kind of directory guaranteed to have no database — the residue a
284
- * successful promotion leaves once the database is renamed out — was the one
285
- * kind nothing could ever delete (KNODIN-2).
286
- *
287
- * Promotion now removes its own directory, so this exists for two other cases:
288
- * residue already accumulated by earlier versions, and directories left by a
289
- * process that died between `allocateCandidate` and writing a database.
290
- *
291
- * Returns the count rather than nothing so callers can say what they swept
292
- * instead of tidying up silently.
293
- */
294
- export function sweepAbandonedCandidates(repo) {
597
+ export function sweepAbandonedCandidates(repo, options = {}) {
295
598
  const resolvedRepo = path.resolve(repo);
296
599
  const root = candidateRoot(resolvedRepo);
297
- let entries;
600
+ const marker = promotionMarkerPath(resolvedRepo);
601
+ let reported = 0;
602
+ const report = (reason, target) => {
603
+ if (reported++ >= 256)
604
+ return;
605
+ try {
606
+ options.onDiagnostic?.({ path: target, reason });
607
+ }
608
+ catch {
609
+ /* Observers cannot authorize cleanup. */
610
+ }
611
+ };
612
+ const exists = (target) => {
613
+ try {
614
+ fs.lstatSync(target);
615
+ return true;
616
+ }
617
+ catch (error) {
618
+ return error.code !== "ENOENT";
619
+ }
620
+ };
621
+ let lease;
298
622
  try {
299
- entries = fs.readdirSync(root, { withFileTypes: true });
623
+ assertStoragePolicyWritable(resolvedRepo);
624
+ const policy = resolveStoragePolicy(resolvedRepo);
625
+ if (!policy?.enabled || policy.dryRun) {
626
+ report("policy-unmanaged-disabled-or-dry-run");
627
+ return 0;
628
+ }
629
+ const state = resolveStateDir(resolvedRepo);
630
+ for (const directory of [state, root]) {
631
+ let stat;
632
+ try {
633
+ stat = fs.lstatSync(directory);
634
+ }
635
+ catch (error) {
636
+ if (directory === root && error.code === "ENOENT")
637
+ return 0;
638
+ throw error;
639
+ }
640
+ if (!stat.isDirectory() || stat.isSymbolicLink())
641
+ return 0;
642
+ }
643
+ const canonicalState = fs.realpathSync(state);
644
+ const canonicalRoot = fs.realpathSync(root);
645
+ const stateIdentity = fs.lstatSync(state);
646
+ const rootIdentity = fs.lstatSync(root);
647
+ const sameDirectory = (target, expected, canonical) => {
648
+ const current = fs.lstatSync(target);
649
+ return (current.isDirectory() &&
650
+ !current.isSymbolicLink() &&
651
+ current.dev === expected.dev &&
652
+ current.ino === expected.ino &&
653
+ fs.realpathSync(target) === canonical);
654
+ };
655
+ if (path.dirname(canonicalRoot) !== canonicalState || exists(marker))
656
+ return 0;
657
+ // Never borrow a same-process outer lease without its explicit owner token.
658
+ // Busy or uncertain coordination is a safe skip, not permission to delete.
659
+ if (exists(path.join(state, "repair.lock")))
660
+ return 0;
661
+ lease = acquireRepairLease(resolvedRepo);
662
+ if (exists(marker))
663
+ return 0;
664
+ let removed = 0;
665
+ const entries = fs.opendirSync(root);
666
+ try {
667
+ for (let inspected = 0; inspected < 256; inspected++) {
668
+ const entry = entries.readSync();
669
+ if (!entry)
670
+ break;
671
+ if (entry.isDirectory() && /^\.gc-private-[A-Za-z0-9_-]+$/.test(entry.name)) {
672
+ const quarantine = path.join(root, entry.name);
673
+ try {
674
+ const identity = fs.lstatSync(quarantine);
675
+ const canonical = path.join(canonicalRoot, entry.name);
676
+ if (!sameDirectory(quarantine, identity, canonical))
677
+ throw new Error("unsafe quarantine preserved");
678
+ const children = fs.opendirSync(quarantine);
679
+ let child;
680
+ try {
681
+ child = children.readSync();
682
+ if (children.readSync())
683
+ throw new Error("unexpected quarantine contents preserved");
684
+ }
685
+ finally {
686
+ children.closeSync();
687
+ }
688
+ const moved = child ? path.join(quarantine, child.name) : null;
689
+ let movedIdentity;
690
+ const names = [];
691
+ if (child && moved) {
692
+ if (!child.isDirectory() || !/^candidate-[A-Za-z0-9_-]+$/.test(child.name))
693
+ throw new Error("unexpected quarantined candidate preserved");
694
+ movedIdentity = fs.lstatSync(moved);
695
+ if (!sameDirectory(moved, movedIdentity, path.join(canonical, child.name)))
696
+ throw new Error("unsafe quarantined candidate preserved");
697
+ const contents = fs.opendirSync(moved);
698
+ try {
699
+ for (let count = 0; count < 3; count++) {
700
+ const file = contents.readSync();
701
+ if (!file)
702
+ break;
703
+ if (![CANDIDATE_OWNER, "status-audit-v1.json"].includes(file.name))
704
+ throw new Error("DB-bearing quarantine preserved");
705
+ const target = path.join(moved, file.name);
706
+ const stat = fs.lstatSync(target);
707
+ if (!stat.isFile() ||
708
+ stat.isSymbolicLink() ||
709
+ stat.size > (file.name === CANDIDATE_OWNER ? 4096 : 1_048_576) ||
710
+ path.dirname(fs.realpathSync(target)) !== fs.realpathSync(moved))
711
+ throw new Error("unsafe quarantine metadata preserved");
712
+ names.push(file.name);
713
+ }
714
+ }
715
+ finally {
716
+ contents.closeSync();
717
+ }
718
+ if (names.length && !candidateOwnerIsDead(readCandidateOwner(moved, movedIdentity)))
719
+ throw new Error("quarantine owner live or unknown");
720
+ }
721
+ const verify = (includeMoved = true) => {
722
+ assertStoragePolicyWritable(resolvedRepo);
723
+ const policy = resolveStoragePolicy(resolvedRepo);
724
+ if (!policy?.enabled ||
725
+ policy.dryRun ||
726
+ exists(marker) ||
727
+ !sameDirectory(state, stateIdentity, canonicalState) ||
728
+ !sameDirectory(root, rootIdentity, canonicalRoot) ||
729
+ !sameDirectory(quarantine, identity, canonical) ||
730
+ (includeMoved &&
731
+ moved &&
732
+ movedIdentity &&
733
+ !sameDirectory(moved, movedIdentity, path.join(canonical, path.basename(moved)))))
734
+ throw new Error("quarantine consent or identity changed");
735
+ };
736
+ if (moved) {
737
+ for (const name of [
738
+ ...names.filter((name) => name !== CANDIDATE_OWNER),
739
+ ...names.filter((name) => name === CANDIDATE_OWNER),
740
+ ]) {
741
+ verify();
742
+ fs.unlinkSync(path.join(moved, name));
743
+ }
744
+ verify();
745
+ fs.rmdirSync(moved);
746
+ }
747
+ verify(false);
748
+ fs.rmdirSync(quarantine);
749
+ removed++;
750
+ }
751
+ catch (error) {
752
+ report(`quarantine-preserved: ${String(error)}`, quarantine);
753
+ }
754
+ continue;
755
+ }
756
+ if (!entry.isDirectory() || !/^candidate-[A-Za-z0-9_-]+$/.test(entry.name))
757
+ continue;
758
+ const directory = path.join(root, entry.name);
759
+ let quarantine;
760
+ let moved;
761
+ try {
762
+ const stat = fs.lstatSync(directory);
763
+ if (!stat.isDirectory() ||
764
+ stat.isSymbolicLink() ||
765
+ fs.realpathSync(directory) !== path.join(canonicalRoot, entry.name))
766
+ continue;
767
+ // Age is an extra fence, never the proof that an owner is absent.
768
+ if (Date.now() - stat.mtimeMs < 60_000)
769
+ continue;
770
+ const owner = readCandidateOwner(directory, stat);
771
+ if (!candidateOwnerIsDead(owner)) {
772
+ report("candidate-owner-live-or-unknown", directory);
773
+ continue;
774
+ }
775
+ const residueFiles = (target, canonical) => {
776
+ const contents = fs.opendirSync(target);
777
+ const names = [];
778
+ try {
779
+ for (let count = 0; count < 3; count++) {
780
+ const child = contents.readSync();
781
+ if (!child)
782
+ break;
783
+ if (![CANDIDATE_OWNER, "status-audit-v1.json"].includes(child.name))
784
+ throw new Error("DB-bearing or unexpected candidate contents preserved");
785
+ const file = path.join(target, child.name);
786
+ const metadata = fs.lstatSync(file);
787
+ if (!metadata.isFile() ||
788
+ metadata.isSymbolicLink() ||
789
+ metadata.size > (child.name === CANDIDATE_OWNER ? 4096 : 1_048_576) ||
790
+ path.dirname(fs.realpathSync(file)) !== canonical)
791
+ throw new Error("unsafe candidate residue file preserved");
792
+ names.push(child.name);
793
+ }
794
+ }
795
+ finally {
796
+ contents.closeSync();
797
+ }
798
+ if (!names.includes(CANDIDATE_OWNER))
799
+ throw new Error("candidate owner marker disappeared");
800
+ return names;
801
+ };
802
+ residueFiles(directory, path.join(canonicalRoot, entry.name));
803
+ if (exists(marker))
804
+ break;
805
+ const currentPolicy = resolveStoragePolicy(resolvedRepo);
806
+ if (!currentPolicy?.enabled || currentPolicy.dryRun)
807
+ throw new Error("storage policy changed; candidate preserved");
808
+ if (!sameDirectory(state, stateIdentity, canonicalState) ||
809
+ !sameDirectory(root, rootIdentity, canonicalRoot) ||
810
+ !sameDirectory(directory, stat, path.join(canonicalRoot, entry.name)))
811
+ throw new Error("candidate/root changed before quarantine");
812
+ quarantine = fs.mkdtempSync(path.join(root, ".gc-private-"));
813
+ fs.chmodSync(quarantine, 0o700);
814
+ const quarantineIdentity = fs.lstatSync(quarantine);
815
+ const canonicalQuarantine = path.join(canonicalRoot, path.basename(quarantine));
816
+ if (!sameDirectory(root, rootIdentity, canonicalRoot) ||
817
+ !sameDirectory(quarantine, quarantineIdentity, canonicalQuarantine))
818
+ throw new Error("quarantine root changed");
819
+ moved = path.join(quarantine, entry.name);
820
+ fs.renameSync(directory, moved);
821
+ const canonicalMoved = path.join(canonicalQuarantine, entry.name);
822
+ if (!sameDirectory(state, stateIdentity, canonicalState) ||
823
+ !sameDirectory(root, rootIdentity, canonicalRoot) ||
824
+ !sameDirectory(quarantine, quarantineIdentity, canonicalQuarantine) ||
825
+ !sameDirectory(moved, stat, canonicalMoved) ||
826
+ exists(marker))
827
+ throw new Error("candidate/root identity changed during quarantine");
828
+ const movedOwner = readCandidateOwner(moved, stat);
829
+ if (movedOwner.nonce !== owner.nonce || !candidateOwnerIsDead(movedOwner))
830
+ throw new Error("candidate ownership changed during quarantine");
831
+ const names = residueFiles(moved, canonicalMoved);
832
+ const finalPolicy = resolveStoragePolicy(resolvedRepo);
833
+ if (!finalPolicy?.enabled || finalPolicy.dryRun)
834
+ throw new Error("storage policy changed; quarantined candidate preserved");
835
+ // Named nonrecursive unlink only after all moved identities/contents validate.
836
+ for (const name of names.filter((name) => name !== CANDIDATE_OWNER))
837
+ fs.unlinkSync(path.join(moved, name));
838
+ fs.unlinkSync(path.join(moved, CANDIDATE_OWNER));
839
+ fs.rmdirSync(moved);
840
+ moved = undefined;
841
+ removed++;
842
+ fs.rmdirSync(quarantine);
843
+ quarantine = undefined;
844
+ }
845
+ catch (error) {
846
+ report(String(error), moved ?? directory);
847
+ // Preserve uncertainty in quarantine. A check-then-rename restore could
848
+ // overwrite a newly allocated directory occupying the original slot.
849
+ if (moved)
850
+ report("quarantine-preserved; inspect explicitly before retrying cleanup", moved);
851
+ if (quarantine && !moved) {
852
+ try {
853
+ fs.rmdirSync(quarantine);
854
+ }
855
+ catch {
856
+ report("quarantine residue preserved", quarantine);
857
+ }
858
+ }
859
+ }
860
+ }
861
+ }
862
+ finally {
863
+ entries.closeSync();
864
+ }
865
+ return removed;
300
866
  }
301
- catch {
867
+ catch (error) {
868
+ report(String(error));
302
869
  return 0;
303
870
  }
304
- let removed = 0;
305
- for (const entry of entries) {
306
- if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
307
- continue;
308
- const directory = path.join(root, entry.name);
309
- if (fs.existsSync(path.join(directory, "db.sqlite")))
310
- continue;
871
+ finally {
311
872
  try {
312
- fs.rmSync(directory, { recursive: true, force: true });
313
- removed++;
873
+ lease?.release();
314
874
  }
315
875
  catch {
316
- // Best effort. Failing to remove residue must not stop the caller's
317
- // actual work, which is what this is being cleaned up alongside.
876
+ /* Cleanup cannot mask the caller's work. */
318
877
  }
319
878
  }
320
- return removed;
321
879
  }
322
880
  export function discardCandidateFiles(repo, candidate) {
323
881
  assertCandidate(repo, candidate);