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.
- package/dist/bin/cli.js +72 -4
- package/dist/src/backup-retention.js +345 -12
- package/dist/src/cli-model.js +12 -0
- package/dist/src/engine/candidate-database.js +624 -66
- package/dist/src/engine/index.js +1405 -242
- package/dist/src/engine/state-paths.js +192 -31
- package/dist/src/manager-update.js +9 -10
- package/dist/src/mirror.js +2 -0
- package/dist/src/shared-index/restore.js +16 -6
- package/dist/src/storage-budget.js +146 -0
- package/dist/src/storage-inventory.js +263 -0
- package/dist/src/storage-management.js +204 -0
- package/dist/src/storage-policy-contract.js +121 -0
- package/dist/src/worktree-seed.js +7 -1
- package/docs/BACKUP-RETENTION.md +46 -2
- package/docs/CLI.md +6 -2
- package/docs/MCP.md +8 -0
- package/docs/releases/0.13.1.md +216 -0
- package/package.json +4 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
removed++;
|
|
873
|
+
lease?.release();
|
|
314
874
|
}
|
|
315
875
|
catch {
|
|
316
|
-
|
|
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);
|