atomic-output.mjs
1982 lines · archify/renderers/shared/atomic-output.mjs at a406594
1import { createHash, randomBytes } from 'node:crypto';
2import fs from 'node:fs';
3import path from 'node:path';
5import { canonicalFuturePath } from './output-path.mjs';
7function relation(status, code, details = {}) {
8 return { status, reason: { code, ...details } };
9}
11function filesystemFailure(code, error, details = {}) {
12 return relation('unknown', code, {
13 ...details,
14 ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
15 ...(typeof error?.message === 'string' ? { message: error.message } : {}),
16 });
17}
19function entryType(metadata) {
20 if (metadata.isFile()) return 'file';
21 if (metadata.isSymbolicLink()) return 'symbolic-link';
22 if (metadata.isDirectory()) return 'directory';
23 if (metadata.isFIFO()) return 'fifo';
24 if (metadata.isSocket()) return 'socket';
25 if (metadata.isBlockDevice()) return 'block-device';
26 if (metadata.isCharacterDevice()) return 'character-device';
27 return 'other';
28}
30// Windows lstat can fall back to directory enumeration when a file handle
31// cannot be opened, and that fallback reports a synthetic link count of one.
32// Treat handle-backed fstat as authoritative and fail closed when it cannot be
33// reconciled with the no-follow path snapshots on either side of the open.
34function captureRegularFileHandle(filePath, initial, subject) {
35 const pathKey = subject === 'target'
36 ? 'commitPath'
37 : subject === 'requested-entry'
38 ? 'requestedPath'
39 : 'candidatePath';
40 if (initial.ino === 0n) {
41 return relation('unknown', `${subject}-identity-unavailable`, { [pathKey]: filePath });
42 }
43 let descriptor;
44 let handle;
45 let current;
46 let closeError;
47 try {
48 const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
49 const nonBlock = fs.constants.O_NONBLOCK || 0;
50 descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonBlock);
51 handle = fs.fstatSync(descriptor, { bigint: true });
52 current = fs.lstatSync(filePath, { bigint: true });
53 } catch (error) {
54 return filesystemFailure(`${subject}-handle-inspection-failed`, error, {
55 [pathKey]: filePath,
56 });
57 } finally {
58 if (descriptor !== undefined) {
59 try {
60 fs.closeSync(descriptor);
61 } catch (error) {
62 closeError = error;
63 }
64 }
65 }
66 if (closeError) {
67 return filesystemFailure(`${subject}-handle-close-failed`, closeError, {
68 [pathKey]: filePath,
69 });
70 }
71 if (!handle.isFile() || !current.isFile()
72 || handle.ino === 0n || current.ino === 0n
73 || handle.dev !== initial.dev || handle.ino !== initial.ino
74 || current.dev !== handle.dev || current.ino !== handle.ino) {
75 return relation('unknown', `${subject}-changed-during-inspection`, {
76 [pathKey]: filePath,
77 });
78 }
79 if (handle.nlink === 0n) {
80 return relation('unknown', `${subject}-link-count-unavailable`, {
81 [pathKey]: filePath,
82 });
83 }
84 if (handle.nlink !== 1n) {
85 return relation('unsupported', `${subject}-hardlinked`, {
86 [pathKey]: filePath,
87 links: handle.nlink.toString(),
88 });
89 }
90 return { status: 'captured', metadata: handle };
91}
93const regularFileBindings = new WeakMap();
94const regularFileBindingGroups = new Map();
95const digestChunkBytes = 64 * 1024;
96const removalCleanupSignal = new Int32Array(new SharedArrayBuffer(4));
97const removalCleanupAttempts = 10;
98const publicationRecoveryRecordName = 'publication-recovery-v1.json';
99const publicationRecoveryRecordVersion = 1;
100const publicationRecoveryRecordMaxBytes = 16 * 1024;
102export function removeEmptyDirectoryWithRetry(directory, { retry = true } = {}) {
103 const attempts = retry ? removalCleanupAttempts : 1;
104 let failure;
105 for (let attempt = 0; attempt < attempts; attempt += 1) {
106 try {
107 fs.rmdirSync(directory);
108 return;
109 } catch (error) {
110 failure = error;
111 // SMB can acknowledge unlink before a following directory removal sees
112 // the deleted entry disappear. Retrying rmdir is claimant-safe: a real
113 // entry keeps the directory non-empty and is never removed recursively.
114 if (error?.code !== 'ENOTEMPTY' || attempt === attempts - 1) break;
115 Atomics.wait(
116 removalCleanupSignal,
117 0,
118 0,
119 Math.min(5 * (2 ** attempt), 250),
120 );
121 }
122 }
123 throw failure;
124}
126function validExpectedLinks(value) {
127 return Number.isSafeInteger(value) && value > 0;
128}
130function validateBindingExpectations(options) {
131 const hasSha256 = options.expectedSha256 !== undefined;
132 const hasBytes = options.expectedBytes !== undefined;
133 const expectedIdentity = options.expectedIdentity;
134 if (hasSha256 !== hasBytes
135 || (hasSha256 && !/^[a-f\d]{64}$/i.test(options.expectedSha256))
136 || (hasBytes && (!Number.isSafeInteger(options.expectedBytes) || options.expectedBytes < 0))
137 || (options.expectedMode !== undefined
138 && (!Number.isSafeInteger(options.expectedMode)
139 || options.expectedMode < 0
140 || options.expectedMode > 0o777))
141 || (expectedIdentity !== undefined
142 && (typeof expectedIdentity?.device !== 'bigint'
143 || typeof expectedIdentity?.inode !== 'bigint'
144 || expectedIdentity.inode === 0n))
145 || typeof options.includeContent !== 'boolean'
146 || !validExpectedLinks(options.expectedLinks)) {
147 return relation('unknown', 'invalid-regular-file-binding-expectation');
148 }
149 return null;
150}
152function descriptorDigest(descriptor, expectedSize, subject, filePath, includeContent) {
153 if (expectedSize < 0n || expectedSize > BigInt(Number.MAX_SAFE_INTEGER)) {
154 return relation('unknown', `${subject}-size-unavailable`, { filePath });
155 }
156 const expectedBytes = Number(expectedSize);
157 const digest = createHash('sha256');
158 let capturedContent;
159 let chunk;
160 try {
161 capturedContent = includeContent ? Buffer.allocUnsafe(expectedBytes) : null;
162 chunk = capturedContent
163 || Buffer.allocUnsafe(Math.min(digestChunkBytes, Math.max(1, expectedBytes)));
164 } catch (error) {
165 return filesystemFailure(`${subject}-content-inspection-failed`, error, { filePath });
166 }
167 let bytes = 0;
168 try {
169 while (bytes < expectedBytes) {
170 const offset = includeContent ? bytes : 0;
171 const read = fs.readSync(
172 descriptor,
173 chunk,
174 offset,
175 Math.min(digestChunkBytes, chunk.byteLength - offset, expectedBytes - bytes),
176 bytes,
177 );
178 if (read === 0) break;
179 digest.update(chunk.subarray(offset, offset + read));
180 bytes += read;
181 }
182 } catch (error) {
183 return filesystemFailure(`${subject}-content-inspection-failed`, error, { filePath });
184 }
185 return {
186 status: 'inspected',
187 sha256: digest.digest('hex'),
188 bytes,
189 ...(includeContent ? { buffer: capturedContent.subarray(0, bytes) } : {}),
190 };
191}
193function sameHandleState(left, right) {
194 return left.dev === right.dev
195 && left.ino === right.ino
196 && left.nlink === right.nlink
197 && left.mode === right.mode
198 && left.size === right.size
199 && left.mtimeNs === right.mtimeNs
200 && left.ctimeNs === right.ctimeNs;
201}
203function inspectRegularFileDescriptor({
204 descriptor,
205 filePath,
206 identity,
207 subject,
208 expectedLinks,
209 phase,
210 includeContent = false,
211}) {
212 let beforeHandle;
213 let beforePath;
214 let afterHandle;
215 let afterPath;
216 let content;
217 try {
218 beforeHandle = fs.fstatSync(descriptor, { bigint: true });
219 beforePath = fs.lstatSync(filePath, { bigint: true });
220 } catch (error) {
221 return filesystemFailure(`${subject}-handle-${phase}-failed`, error, { filePath });
222 }
223 if (!beforeHandle.isFile() || !beforePath.isFile()) {
224 return relation('unsupported', `${subject}-not-regular-file`, {
225 filePath,
226 entryType: entryType(beforePath),
227 });
228 }
229 if (beforeHandle.ino === 0n || beforePath.ino === 0n) {
230 return relation('unknown', `${subject}-identity-unavailable`, { filePath });
231 }
232 if (beforeHandle.dev !== identity.device
233 || beforeHandle.ino !== identity.inode
234 || beforePath.dev !== beforeHandle.dev
235 || beforePath.ino !== beforeHandle.ino) {
236 return relation(phase === 'inspection' ? 'unknown' : 'different',
237 `${subject}-${phase === 'inspection' ? 'changed-during-inspection' : 'identity-changed'}`,
238 { filePath });
239 }
240 if (beforeHandle.nlink === 0n) {
241 return relation('unknown', `${subject}-link-count-unavailable`, { filePath });
242 }
243 if (beforeHandle.nlink !== BigInt(expectedLinks)) {
244 if (beforeHandle.nlink > BigInt(expectedLinks)) {
245 return relation('unsupported', `${subject}-hardlinked`, {
246 filePath,
247 links: beforeHandle.nlink.toString(),
248 });
249 }
250 return relation('different', `${subject}-link-count-changed`, {
251 filePath,
252 expectedLinks,
253 currentLinks: beforeHandle.nlink.toString(),
254 });
255 }
256 content = descriptorDigest(
257 descriptor,
258 beforeHandle.size,
259 subject,
260 filePath,
261 includeContent,
262 );
263 if (content.status !== 'inspected') return content;
264 try {
265 afterHandle = fs.fstatSync(descriptor, { bigint: true });
266 afterPath = fs.lstatSync(filePath, { bigint: true });
267 } catch (error) {
268 return filesystemFailure(`${subject}-handle-${phase}-failed`, error, { filePath });
269 }
270 if (!afterHandle.isFile() || !afterPath.isFile()
271 || afterHandle.ino === 0n || afterPath.ino === 0n
272 || afterHandle.dev !== identity.device || afterHandle.ino !== identity.inode
273 || afterPath.dev !== afterHandle.dev || afterPath.ino !== afterHandle.ino) {
274 return relation(phase === 'inspection' ? 'unknown' : 'different',
275 `${subject}-${phase === 'inspection' ? 'changed-during-inspection' : 'identity-changed'}`,
276 { filePath });
277 }
278 if (!sameHandleState(beforeHandle, afterHandle)) {
279 return relation('unknown', `${subject}-changed-during-${phase}`, { filePath });
280 }
281 if (content.bytes !== Number(afterHandle.size)) {
282 return relation('unknown', `${subject}-changed-during-${phase}`, { filePath });
283 }
284 return { status: 'inspected', metadata: afterHandle, content };
285}
287function closeCapturedDescriptor(descriptor, subject, filePath) {
288 try {
289 fs.closeSync(descriptor);
290 return null;
291 } catch (error) {
292 return filesystemFailure(`${subject}-handle-close-failed`, error, { filePath });
293 }
294}
296/**
297 * Capture a regular file through an open descriptor. The opaque binding keeps
298 * the descriptor alive so callers can prove that the same inode, mode and
299 * bytes are still named by either the original path or a deliberate hard-link
300 * publication path. Callers must release every successfully captured binding.
301 */
302export function captureRegularFileBinding(filePath, {
303 subject = 'file',
304 expectedSha256,
305 expectedBytes,
306 expectedMode,
307 expectedIdentity,
308 expectedLinks = 1,
309 includeContent = false,
310} = {}) {
311 const options = {
312 expectedSha256,
313 expectedBytes,
314 expectedMode,
315 expectedIdentity,
316 expectedLinks,
317 includeContent,
318 };
319 const invalid = validateBindingExpectations(options);
320 if (invalid) return invalid;
321 const resolvedPath = path.resolve(filePath);
322 let initial;
323 try {
324 initial = fs.lstatSync(resolvedPath, { bigint: true });
325 } catch (error) {
326 return filesystemFailure(`${subject}-inspection-failed`, error, { filePath: resolvedPath });
327 }
328 const type = entryType(initial);
329 if (type !== 'file') {
330 return relation('unsupported', `${subject}-not-regular-file`, {
331 filePath: resolvedPath,
332 entryType: type,
333 });
334 }
335 if (initial.ino === 0n) {
336 return relation('unknown', `${subject}-identity-unavailable`, { filePath: resolvedPath });
337 }
339 let descriptor;
340 try {
341 const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
342 const nonBlock = fs.constants.O_NONBLOCK || 0;
343 descriptor = fs.openSync(resolvedPath, fs.constants.O_RDONLY | noFollow | nonBlock);
344 } catch (error) {
345 return filesystemFailure(`${subject}-handle-inspection-failed`, error, { filePath: resolvedPath });
346 }
347 const identity = { device: initial.dev, inode: initial.ino };
348 if (expectedIdentity !== undefined
349 && (identity.device !== expectedIdentity.device
350 || identity.inode !== expectedIdentity.inode)) {
351 const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
352 return closeFailure || relation('different', `${subject}-identity-changed`, {
353 filePath: resolvedPath,
354 });
355 }
356 const inspected = inspectRegularFileDescriptor({
357 descriptor,
358 filePath: resolvedPath,
359 identity,
360 subject,
361 expectedLinks,
362 phase: 'inspection',
363 includeContent,
364 });
365 if (inspected.status !== 'inspected') {
366 const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
367 return closeFailure || inspected;
368 }
369 const mode = Number(inspected.metadata.mode & 0o777n);
370 if (expectedMode !== undefined && mode !== expectedMode) {
371 const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
372 return closeFailure || relation('different', `${subject}-mode-changed`, {
373 filePath: resolvedPath,
374 expectedMode,
375 currentMode: mode,
376 });
377 }
378 if (expectedSha256 !== undefined
379 && (inspected.content.sha256.toLowerCase() !== expectedSha256.toLowerCase()
380 || inspected.content.bytes !== expectedBytes)) {
381 const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
382 return closeFailure || relation('different', `${subject}-content-changed`, {
383 filePath: resolvedPath,
384 expectedSha256,
385 currentSha256: inspected.content.sha256,
386 expectedBytes,
387 currentBytes: inspected.content.bytes,
388 });
389 }
391 const identityKey = `${identity.device}:${identity.inode}`;
392 let cleanupGroup = regularFileBindingGroups.get(identityKey);
393 if (!cleanupGroup) {
394 cleanupGroup = { identityKey, bindings: 0, deferredCleanupDirectories: new Set() };
395 regularFileBindingGroups.set(identityKey, cleanupGroup);
396 }
397 cleanupGroup.bindings += 1;
398 const binding = Object.freeze({});
399 regularFileBindings.set(binding, {
400 descriptor,
401 filePath: resolvedPath,
402 subject,
403 expectedLinks,
404 cleanupGroup,
405 identity,
406 mode,
407 sha256: inspected.content.sha256,
408 bytes: inspected.content.bytes,
409 });
410 return {
411 status: 'captured',
412 binding,
413 identity: { ...identity, mode, links: inspected.metadata.nlink },
414 content: {
415 sha256: inspected.content.sha256,
416 bytes: inspected.content.bytes,
417 ...(includeContent ? { buffer: inspected.content.buffer } : {}),
418 },
419 mode,
420 };
421}
423/** Verify a captured descriptor against its original or an alternate linked path. */
424export function verifyRegularFileBinding(binding, { filePath, expectedLinks } = {}) {
425 const captured = regularFileBindings.get(binding);
426 if (!captured) return relation('unknown', 'regular-file-binding-unavailable');
427 const links = expectedLinks ?? captured.expectedLinks;
428 if (!validExpectedLinks(links)) {
429 return relation('unknown', 'invalid-regular-file-binding-expectation');
430 }
431 const verificationPath = path.resolve(filePath ?? captured.filePath);
432 const inspected = inspectRegularFileDescriptor({
433 descriptor: captured.descriptor,
434 filePath: verificationPath,
435 identity: captured.identity,
436 subject: captured.subject,
437 expectedLinks: links,
438 phase: 'verification',
439 });
440 if (inspected.status !== 'inspected') return inspected;
441 const mode = Number(inspected.metadata.mode & 0o777n);
442 if (mode !== captured.mode) {
443 return relation('different', `${captured.subject}-mode-changed`, {
444 filePath: verificationPath,
445 previousMode: captured.mode,
446 currentMode: mode,
447 });
448 }
449 if (inspected.content.sha256 !== captured.sha256
450 || inspected.content.bytes !== captured.bytes) {
451 return relation('different', `${captured.subject}-content-changed`, {
452 filePath: verificationPath,
453 previousSha256: captured.sha256,
454 currentSha256: inspected.content.sha256,
455 previousBytes: captured.bytes,
456 currentBytes: inspected.content.bytes,
457 });
458 }
459 return relation('match', `${captured.subject}-binding-match`, {
460 filePath: verificationPath,
461 links,
462 });
463}
465/** Close and invalidate a captured regular-file binding. */
466export function releaseRegularFileBinding(binding) {
467 const captured = regularFileBindings.get(binding);
468 if (!captured) return relation('unknown', 'regular-file-binding-unavailable');
469 regularFileBindings.delete(binding);
470 const failure = closeCapturedDescriptor(
471 captured.descriptor,
472 captured.subject,
473 captured.filePath,
474 );
475 const { cleanupGroup } = captured;
476 cleanupGroup.bindings -= 1;
477 let cleanupFailure;
478 if (cleanupGroup.bindings === 0) {
479 regularFileBindingGroups.delete(cleanupGroup.identityKey);
480 // A staging registry and its publication transaction can bind the same
481 // inode independently. SMB may keep a retired name visible until every
482 // descriptor closes, so cleanup belongs to the inode's last binding.
483 for (const directory of cleanupGroup.deferredCleanupDirectories) {
484 const cleanup = removeEmptyQuarantine(directory, { retry: true });
485 cleanupFailure ||= cleanup;
486 }
487 }
488 if (failure || cleanupFailure) return failure || cleanupFailure;
489 return relation('released', `${captured.subject}-binding-released`, {
490 filePath: captured.filePath,
491 });
492}
494function createRemovalQuarantine(parentPath) {
495 for (let attempt = 0; attempt < 8; attempt += 1) {
496 const directory = path.join(
497 parentPath,
498 `.archify-remove-${randomBytes(16).toString('hex')}`,
499 );
500 try {
501 fs.mkdirSync(directory, { mode: 0o700 });
502 return { status: 'created', directory };
503 } catch (error) {
504 if (error?.code !== 'EEXIST') {
505 return filesystemFailure('removal-quarantine-create-failed', error, {
506 parentPath,
507 });
508 }
509 }
510 }
511 return relation('unknown', 'removal-quarantine-name-exhausted', { parentPath });
512}
514function removeEmptyQuarantine(directory, { retry = false } = {}) {
515 let failure;
516 try {
517 removeEmptyDirectoryWithRetry(directory, { retry });
518 return null;
519 } catch (error) {
520 failure = error;
521 }
522 return filesystemFailure('removal-quarantine-cleanup-failed', failure, {
523 recoveryDirectory: directory,
524 });
525}
527function deferEmptyQuarantineCleanup(binding, cleanup) {
528 if (cleanup?.reason?.code !== 'removal-quarantine-cleanup-failed'
529 || cleanup.reason.systemCode !== 'ENOTEMPTY') return false;
530 const captured = regularFileBindings.get(binding);
531 if (!captured) return false;
532 captured.cleanupGroup.deferredCleanupDirectories.add(cleanup.reason.recoveryDirectory);
533 return true;
534}
536function removalRecovery(subject, code, filePath, quarantineDirectory, quarantineFile, error) {
537 return {
538 status: 'recovery-required',
539 reason: {
540 code: `${subject}-${code}`,
541 filePath,
542 ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
543 ...(error?.message ? { message: error.message } : {}),
544 },
545 recoveryDirectory: quarantineDirectory,
546 recoveryFile: quarantineFile,
547 target: filePath,
548 };
549}
551function decimal(value) {
552 return value.toString();
553}
555function parseDecimal(value) {
556 if (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value)) return null;
557 try {
558 return BigInt(value);
559 } catch {
560 return null;
561 }
562}
564function parseSafeInteger(value) {
565 return Number.isSafeInteger(value) && value >= 0 ? value : null;
566}
568function serializeRequestedEntry(entry) {
569 if (entry?.kind === 'absent') return { kind: 'absent' };
570 if (entry?.kind !== 'existing' || typeof entry.entryType !== 'string'
571 || typeof entry.device !== 'bigint' || typeof entry.inode !== 'bigint') {
572 return null;
573 }
574 return {
575 kind: 'existing',
576 entryType: entry.entryType,
577 device: decimal(entry.device),
578 inode: decimal(entry.inode),
579 ...(entry.entryType === 'symbolic-link' ? {
580 changedAtNs: decimal(entry.changedAtNs),
581 createdAtNs: decimal(entry.createdAtNs),
582 } : {}),
583 };
584}
586function createPublicationRecoveryRecord(directory, snapshot, previous) {
587 const requestedEntry = serializeRequestedEntry(snapshot?.requestedEntry);
588 const slot = snapshot?.slot;
589 if (!requestedEntry || !slot || typeof snapshot?.requestedPath !== 'string'
590 || typeof snapshot?.requestedEntryPolicy !== 'string'
591 || typeof slot.commitPath !== 'string' || typeof slot.parentPath !== 'string'
592 || typeof slot.name !== 'string' || typeof slot.parentDevice !== 'bigint'
593 || typeof slot.parentInode !== 'bigint' || !previous?.identity
594 || !previous?.content || !Number.isSafeInteger(previous.mode)) {
595 return relation('unknown', 'publication-recovery-record-input-invalid');
596 }
597 const recordPath = path.join(directory, publicationRecoveryRecordName);
598 let directoryMetadata;
599 let parentMetadata;
600 try {
601 directoryMetadata = fs.lstatSync(directory, { bigint: true });
602 parentMetadata = fs.statSync(path.dirname(directory), { bigint: true });
603 } catch (error) {
604 return filesystemFailure('publication-recovery-directory-inspection-failed', error, {
605 recoveryDirectory: directory,
606 });
607 }
608 if (!directoryMetadata.isDirectory() || directoryMetadata.ino === 0n
609 || !parentMetadata.isDirectory()
610 || parentMetadata.dev !== slot.parentDevice || parentMetadata.ino !== slot.parentInode
611 || !/^\.archify-remove-[a-f\d]{32}$/.test(path.basename(directory))) {
612 return relation('unknown', 'publication-recovery-directory-invalid', {
613 recoveryDirectory: directory,
614 });
615 }
616 const record = {
617 version: publicationRecoveryRecordVersion,
618 kind: 'archify-retired-output-recovery',
619 backup: {
620 name: 'previous',
621 sha256: previous.content.sha256,
622 bytes: previous.content.bytes,
623 mode: previous.mode,
624 device: decimal(previous.identity.device),
625 inode: decimal(previous.identity.inode),
626 },
627 recovery: {
628 name: path.basename(directory),
629 device: decimal(directoryMetadata.dev),
630 inode: decimal(directoryMetadata.ino),
631 },
632 target: {
633 requestedPath: snapshot.requestedPath,
634 requestedEntryPolicy: snapshot.requestedEntryPolicy,
635 requestedEntry,
636 commitPath: slot.commitPath,
637 parentPath: slot.parentPath,
638 parentDevice: decimal(slot.parentDevice),
639 parentInode: decimal(slot.parentInode),
640 name: slot.name,
641 },
642 };
643 const bytes = Buffer.from(`${JSON.stringify(record)}\n`);
644 let descriptor;
645 let writeFailure;
646 try {
647 descriptor = fs.openSync(
648 recordPath,
649 fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
650 0o600,
651 );
652 let offset = 0;
653 while (offset < bytes.byteLength) {
654 const wrote = fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
655 if (wrote <= 0) throw Object.assign(new Error('short recovery record write'), { code: 'EIO' });
656 offset += wrote;
657 }
658 fs.fsyncSync(descriptor);
659 } catch (error) {
660 writeFailure = error;
661 } finally {
662 if (descriptor !== undefined) {
663 try {
664 fs.closeSync(descriptor);
665 } catch (error) {
666 writeFailure ||= error;
667 }
668 }
669 }
670 if (writeFailure) {
671 return filesystemFailure('publication-recovery-record-write-failed', writeFailure, {
672 recoveryDirectory: directory,
673 recoveryFile: recordPath,
674 });
675 }
676 // POSIX requires the parent directory entry to be synced separately. Do not
677 // claim a kill-recovery record is durable where the platform cannot do that.
678 if (process.platform !== 'win32') {
679 let directoryDescriptor;
680 let directoryFailure;
681 try {
682 directoryDescriptor = fs.openSync(directory, fs.constants.O_RDONLY);
683 fs.fsyncSync(directoryDescriptor);
684 } catch (error) {
685 directoryFailure = error;
686 } finally {
687 if (directoryDescriptor !== undefined) {
688 try {
689 fs.closeSync(directoryDescriptor);
690 } catch (error) {
691 directoryFailure ||= error;
692 }
693 }
694 }
695 if (directoryFailure) {
696 return filesystemFailure('publication-recovery-record-directory-sync-failed', directoryFailure, {
697 recoveryDirectory: directory,
698 recoveryFile: recordPath,
699 });
700 }
701 }
702 const captured = captureRegularFileBinding(recordPath, {
703 subject: 'publication-recovery-record',
704 expectedSha256: createHash('sha256').update(bytes).digest('hex'),
705 expectedBytes: bytes.byteLength,
706 // Windows reports a synthetic POSIX mode whose read bits cannot represent
707 // the private ACL established by the exclusive create above.
708 expectedMode: process.platform === 'win32' ? undefined : 0o600,
709 expectedLinks: 1,
710 });
711 if (captured.status !== 'captured') {
712 return {
713 ...captured,
714 recoveryDirectory: directory,
715 recoveryFile: recordPath,
716 };
717 }
718 return {
719 status: 'created',
720 recordPath,
721 binding: captured.binding,
722 };
723}
725function discardPublicationRecoveryRecord(record) {
726 if (!record?.binding || typeof record.recordPath !== 'string') return null;
727 let removed;
728 let released;
729 try {
730 removed = quarantineRemoveRegularFileBinding(record.binding, record.recordPath, {
731 subject: 'publication-recovery-record',
732 expectedLinks: 1,
733 });
734 } finally {
735 released = releaseRegularFileBinding(record.binding);
736 }
737 if (released.status !== 'released') return released;
738 return removed.status === 'removed' ? null : removed;
739}
741function releasePublicationRecoveryRecord(record) {
742 if (!record?.binding) return null;
743 const released = releaseRegularFileBinding(record.binding);
744 return released.status === 'released' ? null : released;
745}
747/**
748 * Remove a public regular-file name without a verify-to-unlink race. The
749 * public entry is first moved into an unpredictable private directory and is
750 * deleted only after the caller's still-open binding proves that the moved
751 * inode, mode and bytes are the owned entry. If the move captured a successor,
752 * restore it with a no-clobber hard link or retain it as recovery material.
753 * The caller retains ownership of `binding` and must release it separately.
754 */
755export function quarantineRemoveRegularFileBinding(binding, filePath, {
756 subject = 'public-entry',
757 expectedLinks = 1,
758} = {}) {
759 if (!validExpectedLinks(expectedLinks)) {
760 return relation('unknown', 'invalid-regular-file-binding-expectation');
761 }
762 const resolvedPath = path.resolve(filePath);
763 const beforeMove = verifyRegularFileBinding(binding, {
764 filePath: resolvedPath,
765 expectedLinks,
766 });
767 if (beforeMove.status !== 'match') return beforeMove;
769 const quarantine = createRemovalQuarantine(path.dirname(resolvedPath));
770 if (quarantine.status !== 'created') return quarantine;
771 const quarantineFile = path.join(quarantine.directory, path.basename(resolvedPath));
772 const removeEmpty = () => removeEmptyQuarantine(quarantine.directory);
773 const removeEmptyAfterFileRemoval = () => {
774 const cleanup = removeEmpty();
775 return cleanup && deferEmptyQuarantineCleanup(binding, cleanup) ? null : cleanup;
776 };
778 try {
779 fs.renameSync(resolvedPath, quarantineFile);
780 } catch (error) {
781 const cleanup = removeEmpty();
782 return cleanup || filesystemFailure(`${subject}-quarantine-move-failed`, error, {
783 filePath: resolvedPath,
784 });
785 }
787 const moved = verifyRegularFileBinding(binding, {
788 filePath: quarantineFile,
789 expectedLinks,
790 });
791 if (moved.status === 'match') {
792 try {
793 fs.unlinkSync(quarantineFile);
794 } catch (error) {
795 return removalRecovery(
796 subject,
797 'quarantine-cleanup-failed',
798 resolvedPath,
799 quarantine.directory,
800 quarantineFile,
801 error,
802 );
803 }
804 const cleanup = removeEmptyAfterFileRemoval();
805 return cleanup || relation('removed', `${subject}-removed`, { filePath: resolvedPath });
806 }
808 let displaced;
809 try {
810 displaced = fs.lstatSync(quarantineFile, { bigint: true });
811 } catch (error) {
812 return removalRecovery(
813 subject,
814 'replacement-inspection-failed',
815 resolvedPath,
816 quarantine.directory,
817 quarantineFile,
818 error,
819 );
820 }
821 if (displaced.ino === 0n || displaced.nlink === 0n) {
822 return removalRecovery(
823 subject,
824 'replacement-identity-unavailable',
825 resolvedPath,
826 quarantine.directory,
827 quarantineFile,
828 );
829 }
830 try {
831 fs.linkSync(quarantineFile, resolvedPath);
832 } catch (error) {
833 return removalRecovery(
834 subject,
835 error?.code === 'EEXIST' ? 'replacement-restore-blocked' : 'replacement-restore-failed',
836 resolvedPath,
837 quarantine.directory,
838 quarantineFile,
839 error,
840 );
841 }
843 let restored;
844 let retained;
845 try {
846 restored = fs.lstatSync(resolvedPath, { bigint: true });
847 retained = fs.lstatSync(quarantineFile, { bigint: true });
848 } catch (error) {
849 return removalRecovery(
850 subject,
851 'replacement-restore-verification-failed',
852 resolvedPath,
853 quarantine.directory,
854 quarantineFile,
855 error,
856 );
857 }
858 if (restored.dev !== displaced.dev || restored.ino !== displaced.ino
859 || retained.dev !== displaced.dev || retained.ino !== displaced.ino
860 || restored.nlink !== displaced.nlink + 1n
861 || retained.nlink !== displaced.nlink + 1n) {
862 return removalRecovery(
863 subject,
864 'replacement-restore-identity-changed',
865 resolvedPath,
866 quarantine.directory,
867 quarantineFile,
868 );
869 }
870 try {
871 fs.unlinkSync(quarantineFile);
872 } catch (error) {
873 return removalRecovery(
874 subject,
875 'replacement-quarantine-cleanup-failed',
876 resolvedPath,
877 quarantine.directory,
878 quarantineFile,
879 error,
880 );
881 }
882 try {
883 restored = fs.lstatSync(resolvedPath, { bigint: true });
884 } catch (error) {
885 const cleanup = removeEmptyAfterFileRemoval();
886 return cleanup || filesystemFailure(
887 `${subject}-replacement-final-verification-failed`,
888 error,
889 { filePath: resolvedPath },
890 );
891 }
892 if (restored.dev !== displaced.dev || restored.ino !== displaced.ino
893 || restored.nlink !== displaced.nlink) {
894 const cleanup = removeEmptyAfterFileRemoval();
895 return cleanup || relation('different', `${subject}-replacement-final-identity-changed`, {
896 filePath: resolvedPath,
897 });
898 }
899 const cleanup = removeEmptyAfterFileRemoval();
900 return cleanup || relation('preserved', `${subject}-replacement-restored`, {
901 filePath: resolvedPath,
902 priorState: moved.reason,
903 });
904}
906/**
907 * Create a no-clobber hard-link backup of a bound public file, then remove the
908 * public name through the quarantine protocol above. The backup is reported as
909 * verified only after both names have been reconciled with the open binding.
910 */
911export function backupPublicRegularFileBinding(binding, filePath, backupPath, {
912 subject = 'public-entry',
913} = {}) {
914 const resolvedPath = path.resolve(filePath);
915 const resolvedBackup = path.resolve(backupPath);
916 const beforeLink = verifyRegularFileBinding(binding, {
917 filePath: resolvedPath,
918 expectedLinks: 1,
919 });
920 if (beforeLink.status !== 'match') {
921 return { ...beforeLink, backupCreated: false, backupVerified: false };
922 }
923 try {
924 fs.linkSync(resolvedPath, resolvedBackup);
925 } catch (error) {
926 return {
927 ...filesystemFailure(`${subject}-backup-link-failed`, error, {
928 filePath: resolvedPath,
929 backupPath: resolvedBackup,
930 }),
931 backupCreated: false,
932 backupVerified: false,
933 };
934 }
935 for (const linkedPath of [resolvedPath, resolvedBackup]) {
936 const linked = verifyRegularFileBinding(binding, {
937 filePath: linkedPath,
938 expectedLinks: 2,
939 });
940 if (linked.status !== 'match') {
941 return {
942 status: 'recovery-required',
943 reason: {
944 code: `${subject}-backup-binding-mismatch`,
945 filePath: resolvedPath,
946 backupPath: resolvedBackup,
947 linkedState: linked.reason,
948 },
949 recoveryDirectory: path.dirname(resolvedBackup),
950 recoveryFile: resolvedBackup,
951 target: resolvedPath,
952 backupCreated: true,
953 backupVerified: false,
954 };
955 }
956 }
957 const removed = quarantineRemoveRegularFileBinding(binding, resolvedPath, {
958 subject,
959 expectedLinks: 2,
960 });
961 if (removed.status !== 'removed') {
962 return {
963 ...removed,
964 backupPath: resolvedBackup,
965 backupCreated: true,
966 backupVerified: true,
967 };
968 }
969 const finalized = verifyRegularFileBinding(binding, {
970 filePath: resolvedBackup,
971 expectedLinks: 1,
972 });
973 if (finalized.status !== 'match') {
974 return {
975 status: 'recovery-required',
976 reason: {
977 code: `${subject}-backup-finalization-failed`,
978 filePath: resolvedPath,
979 backupPath: resolvedBackup,
980 backupState: finalized.reason,
981 },
982 recoveryDirectory: path.dirname(resolvedBackup),
983 recoveryFile: resolvedBackup,
984 target: resolvedPath,
985 backupCreated: true,
986 backupVerified: false,
987 };
988 }
989 return {
990 status: 'backed-up',
991 reason: {
992 code: `${subject}-backed-up`,
993 filePath: resolvedPath,
994 backupPath: resolvedBackup,
995 },
996 backupPath: resolvedBackup,
997 backupCreated: true,
998 backupVerified: true,
999 };
1000}
1002/**
1003 * Remove a hard-link name that this process just introduced while preserving
1004 * the source claimant. This is the rollback path when a staged pathname was
1005 * swapped after its expected binding was verified but before `linkSync` used
1006 * that pathname.
1007 */
1008export function quarantineRemoveLinkedRegularFileAlias(sourcePath, aliasPath, {
1009 subject = 'linked-claimant',
1010} = {}) {
1011 const source = path.resolve(sourcePath);
1012 const alias = path.resolve(aliasPath);
1013 const captured = captureRegularFileBinding(source, {
1014 subject,
1015 expectedLinks: 2,
1016 });
1017 if (captured.status !== 'captured') return captured;
1018 let result;
1019 let released;
1020 try {
1021 const aliasMatches = verifyRegularFileBinding(captured.binding, {
1022 filePath: alias,
1023 expectedLinks: 2,
1024 });
1025 if (aliasMatches.status !== 'match') {
1026 result = aliasMatches;
1027 } else {
1028 const removal = quarantineRemoveRegularFileBinding(captured.binding, alias, {
1029 subject,
1030 expectedLinks: 2,
1031 });
1032 if (removal.status !== 'removed') {
1033 result = removal;
1034 } else {
1035 const sourceRemains = verifyRegularFileBinding(captured.binding, {
1036 filePath: source,
1037 expectedLinks: 1,
1038 });
1039 result = sourceRemains.status === 'match' ? removal : sourceRemains;
1040 }
1041 }
1042 } finally {
1043 released = releaseRegularFileBinding(captured.binding);
1044 }
1045 if (released.status !== 'released') {
1046 return {
1047 ...released,
1048 removalStatus: result?.status,
1049 ...(result?.recoveryDirectory ? {
1050 recoveryDirectory: result.recoveryDirectory,
1051 recoveryFile: result.recoveryFile,
1052 target: result.target,
1053 } : {}),
1054 };
1055 }
1056 return result;
1057}
1059function removePublishedBindingName(binding, filePath, subject) {
1060 for (const expectedLinks of [2, 1]) {
1061 const matches = verifyRegularFileBinding(binding, { filePath, expectedLinks });
1062 if (matches.status === 'match') {
1063 return quarantineRemoveRegularFileBinding(binding, filePath, {
1064 subject,
1065 expectedLinks,
1066 });
1067 }
1068 }
1069 return relation('preserved', `${subject}-identity-changed`, { filePath });
1070}
1072function restorePublicationBackup(binding, backupPath, commitPath, subject) {
1073 const backupMatches = verifyRegularFileBinding(binding, {
1074 filePath: backupPath,
1075 expectedLinks: 1,
1076 });
1077 if (backupMatches.status !== 'match') return backupMatches;
1078 try {
1079 fs.linkSync(backupPath, commitPath);
1080 } catch (error) {
1081 return filesystemFailure(
1082 error?.code === 'EEXIST'
1083 ? `${subject}-restore-blocked`
1084 : `${subject}-restore-failed`,
1085 error,
1086 { backupPath, commitPath },
1087 );
1088 }
1089 for (const linkedPath of [backupPath, commitPath]) {
1090 const linked = verifyRegularFileBinding(binding, {
1091 filePath: linkedPath,
1092 expectedLinks: 2,
1093 });
1094 if (linked.status !== 'match') return linked;
1095 }
1096 const retired = quarantineRemoveRegularFileBinding(binding, backupPath, {
1097 subject: `${subject}-backup`,
1098 expectedLinks: 2,
1099 });
1100 if (retired.status !== 'removed') return retired;
1101 return verifyRegularFileBinding(binding, {
1102 filePath: commitPath,
1103 expectedLinks: 1,
1104 });
1105}
1107/**
1108 * Publish a captured candidate without ever replacing an unverified public
1109 * entry. Existing output is retained through a bound private backup; the new
1110 * name is created with a no-clobber hard link while the candidate descriptor
1111 * remains open. Both the staged and public names are then verified through
1112 * that descriptor before the staged name is retired via quarantine.
1113 * Replacing an existing entry is recoverable but not crash-atomic: the public
1114 * name is absent between retiring the verified previous name and linking the
1115 * candidate. Portable Node.js exposes no replacement compare-and-swap that
1116 * also preserves a late claimant.
1117 *
1118 * The caller owns `binding` and must release it after this function returns.
1119 */
1120export function publishRegularFileBinding(binding, candidatePath, snapshot, {
1121 subject = 'candidate',
1122} = {}) {
1123 const candidate = path.resolve(candidatePath);
1124 const commitPath = snapshot?.slot?.commitPath;
1125 if (typeof commitPath !== 'string') {
1126 return relation('unknown', `${subject}-publication-snapshot-unavailable`);
1127 }
1129 const beforePublish = verifyAtomicOutput(snapshot);
1130 if (beforePublish.status !== 'match') return beforePublish;
1131 const candidateMatches = verifyRegularFileBinding(binding, {
1132 filePath: candidate,
1133 expectedLinks: 1,
1134 });
1135 if (candidateMatches.status !== 'match') return candidateMatches;
1137 let previousBinding;
1138 let recoveryDirectory;
1139 let backupPath;
1140 let recoveryRecord;
1141 let previousBackedUp = false;
1142 const finishPrevious = () => {
1143 if (!previousBinding) return null;
1144 const released = releaseRegularFileBinding(previousBinding);
1145 previousBinding = undefined;
1146 return released.status === 'released' ? null : released;
1147 };
1148 const cleanupRecoveryDirectory = () => {
1149 if (!recoveryDirectory) return null;
1150 const cleanup = removeEmptyQuarantine(recoveryDirectory);
1151 if (!cleanup) recoveryDirectory = undefined;
1152 return cleanup;
1153 };
1154 const restorePrevious = () => {
1155 if (!previousBinding || !previousBackedUp) return null;
1156 return restorePublicationBackup(
1157 previousBinding,
1158 backupPath,
1159 commitPath,
1160 'previous-output',
1161 );
1162 };
1163 const rollback = (failure) => {
1164 let publicRemoval = removePublishedBindingName(
1165 binding,
1166 commitPath,
1167 `${subject}-publication`,
1168 );
1169 if (publicRemoval.status === 'preserved') {
1170 const claimantRemoval = quarantineRemoveLinkedRegularFileAlias(
1171 candidate,
1172 commitPath,
1173 { subject: `${subject}-publication-claimant` },
1174 );
1175 if (claimantRemoval.status === 'removed') publicRemoval = claimantRemoval;
1176 }
1177 const restored = restorePrevious();
1178 const recordCleanup = restored?.status === 'match'
1179 ? discardPublicationRecoveryRecord(recoveryRecord)
1180 : releasePublicationRecoveryRecord(recoveryRecord);
1181 recoveryRecord = undefined;
1182 const released = finishPrevious();
1183 const directoryCleanup = cleanupRecoveryDirectory();
1184 return {
1185 ...failure,
1186 ...(publicRemoval.status !== 'removed' && publicRemoval.status !== 'absent'
1187 ? { publicState: publicRemoval.reason } : {}),
1188 ...(restored && restored.status !== 'match' ? { restoreState: restored.reason } : {}),
1189 ...(recordCleanup ? { recoveryRecordState: recordCleanup.reason } : {}),
1190 ...(released ? { releaseState: released.reason } : {}),
1191 ...(directoryCleanup ? {
1192 recoveryDirectory: directoryCleanup.reason?.recoveryDirectory || recoveryDirectory,
1193 } : {}),
1194 ...((restored && restored.status !== 'match') || recordCleanup || directoryCleanup
1195 ? { recoveryFile: backupPath } : {}),
1196 };
1197 };
1199 if (snapshot.target.kind === 'file') {
1200 const previous = captureRegularFileBinding(commitPath, {
1201 subject: 'previous-output',
1202 expectedIdentity: {
1203 device: snapshot.target.device,
1204 inode: snapshot.target.inode,
1205 },
1206 expectedMode: snapshot.target.mode,
1207 expectedLinks: 1,
1208 });
1209 if (previous.status !== 'captured') return previous;
1210 previousBinding = previous.binding;
1211 const quarantine = createRemovalQuarantine(path.dirname(commitPath));
1212 if (quarantine.status !== 'created') {
1213 const released = finishPrevious();
1214 return released || quarantine;
1215 }
1216 recoveryDirectory = quarantine.directory;
1217 backupPath = path.join(recoveryDirectory, 'previous');
1218 const recovery = createPublicationRecoveryRecord(recoveryDirectory, snapshot, previous);
1219 if (recovery.status !== 'created') {
1220 const released = finishPrevious();
1221 const directoryCleanup = cleanupRecoveryDirectory();
1222 return {
1223 ...recovery,
1224 ...(recoveryDirectory ? { recoveryDirectory } : {}),
1225 ...(released ? { releaseState: released.reason } : {}),
1226 ...(directoryCleanup ? { directoryCleanupState: directoryCleanup.reason } : {}),
1227 };
1228 }
1229 recoveryRecord = recovery;
1230 const backup = backupPublicRegularFileBinding(
1231 previousBinding,
1232 commitPath,
1233 backupPath,
1234 { subject: 'previous-output' },
1235 );
1236 previousBackedUp = backup.status === 'backed-up';
1237 if (!previousBackedUp) {
1238 const recordCleanup = backup.backupCreated
1239 ? releasePublicationRecoveryRecord(recoveryRecord)
1240 : discardPublicationRecoveryRecord(recoveryRecord);
1241 recoveryRecord = undefined;
1242 const released = finishPrevious();
1243 const directoryCleanup = backup.backupCreated ? null : cleanupRecoveryDirectory();
1244 return {
1245 ...backup,
1246 ...(recordCleanup ? { recoveryRecordState: recordCleanup.reason } : {}),
1247 ...(released ? { releaseState: released.reason } : {}),
1248 ...(directoryCleanup ? { directoryCleanupState: directoryCleanup.reason } : {}),
1249 };
1250 }
1251 }
1253 try {
1254 fs.linkSync(candidate, commitPath);
1255 } catch (error) {
1256 return rollback(filesystemFailure(
1257 error?.code === 'EEXIST'
1258 ? 'target-created-during-commit'
1259 : `${subject}-publication-link-failed`,
1260 error,
1261 { candidatePath: candidate, commitPath },
1262 ));
1263 }
1265 const published = verifyRegularFileBinding(binding, {
1266 filePath: commitPath,
1267 expectedLinks: 2,
1268 });
1269 const staged = verifyRegularFileBinding(binding, {
1270 filePath: candidate,
1271 expectedLinks: 2,
1272 });
1273 if (published.status !== 'match' || staged.status !== 'match') {
1274 return rollback(relation('different', `${subject}-identity-changed`, {
1275 candidatePath: candidate,
1276 commitPath,
1277 publishedState: published.reason,
1278 stagedState: staged.reason,
1279 }));
1280 }
1282 const retired = quarantineRemoveRegularFileBinding(binding, candidate, {
1283 subject: `${subject}-staging`,
1284 expectedLinks: 2,
1285 });
1286 if (retired.status !== 'removed' && retired.status !== 'preserved') {
1287 return rollback(retired);
1288 }
1289 const final = verifyRegularFileBinding(binding, {
1290 filePath: commitPath,
1291 expectedLinks: 1,
1292 });
1293 if (final.status !== 'match') return rollback(final);
1295 let cleanupWarning = retired.status === 'preserved' ? retired : undefined;
1296 if (previousBinding) {
1297 const backupRemoval = quarantineRemoveRegularFileBinding(
1298 previousBinding,
1299 backupPath,
1300 { subject: 'previous-output-backup', expectedLinks: 1 },
1301 );
1302 if (backupRemoval.status !== 'removed') cleanupWarning = backupRemoval;
1303 const recordCleanup = backupRemoval.status === 'removed'
1304 ? discardPublicationRecoveryRecord(recoveryRecord)
1305 : releasePublicationRecoveryRecord(recoveryRecord);
1306 recoveryRecord = undefined;
1307 cleanupWarning ||= recordCleanup;
1308 }
1309 const released = finishPrevious();
1310 const directoryCleanup = cleanupRecoveryDirectory();
1311 cleanupWarning ||= released || directoryCleanup;
1312 const recoveryFile = cleanupWarning?.recoveryFile
1313 || (retired.status === 'preserved' ? candidate : undefined)
1314 || (recoveryDirectory ? backupPath : undefined);
1315 return {
1316 status: cleanupWarning ? 'committed-with-warning' : 'committed',
1317 reason: {
1318 code: `${subject}-published`,
1319 candidatePath: candidate,
1320 commitPath,
1321 ...(retired.status === 'preserved' ? { stagedSuccessorPreserved: true } : {}),
1322 },
1323 ...(cleanupWarning ? {
1324 cleanupState: cleanupWarning.reason,
1325 ...(recoveryDirectory ? { recoveryDirectory } : {}),
1326 ...(recoveryFile ? { recoveryFile } : {}),
1327 } : {}),
1328 };
1329}
1331/** Remove only the directory entry that still names a caller-owned inode. */
1332export function removeOwnedRegularFile(filePath, identity, { subject = 'candidate' } = {}) {
1333 const resolvedPath = path.resolve(filePath);
1334 let expectedLinks = 1;
1335 let captured = captureRegularFileBinding(resolvedPath, {
1336 subject,
1337 expectedIdentity: identity,
1338 expectedLinks,
1339 });
1340 const reportedLinks = Number(captured.reason?.links);
1341 if (captured.status === 'unsupported'
1342 && captured.reason?.code === `${subject}-hardlinked`
1343 && Number.isSafeInteger(reportedLinks)
1344 && reportedLinks > 1) {
1345 expectedLinks = reportedLinks;
1346 captured = captureRegularFileBinding(resolvedPath, {
1347 subject,
1348 expectedIdentity: identity,
1349 expectedLinks,
1350 });
1351 }
1352 if (captured.status !== 'captured') {
1353 if (captured.reason?.systemCode === 'ENOENT') {
1354 return relation('absent', `${subject}-already-absent`, { filePath: resolvedPath });
1355 }
1356 if (captured.status === 'different' || captured.status === 'unsupported') {
1357 return relation('preserved', `${subject}-identity-changed`, {
1358 filePath: resolvedPath,
1359 priorState: captured.reason,
1360 });
1361 }
1362 return captured;
1363 }
1365 let removal;
1366 let released;
1367 try {
1368 removal = quarantineRemoveRegularFileBinding(captured.binding, resolvedPath, {
1369 subject,
1370 expectedLinks,
1371 });
1372 } finally {
1373 released = releaseRegularFileBinding(captured.binding);
1374 }
1375 if (released.status !== 'released') {
1376 return {
1377 ...released,
1378 removalStatus: removal?.status,
1379 ...(removal?.recoveryDirectory ? {
1380 recoveryDirectory: removal.recoveryDirectory,
1381 recoveryFile: removal.recoveryFile,
1382 target: removal.target,
1383 } : {}),
1384 };
1385 }
1386 return removal;
1387}
1389function captureWriteSlot(requestedPath) {
1390 let commitPath;
1391 try {
1392 commitPath = canonicalFuturePath(requestedPath);
1393 } catch (error) {
1394 const diagnostic = error?.archifyDiagnostics?.[0];
1395 return relation('unknown', 'canonicalization-failed', {
1396 ...(diagnostic?.code ? { diagnosticCode: diagnostic.code } : {}),
1397 ...(diagnostic?.evidence?.relation ? { relation: diagnostic.evidence.relation } : {}),
1398 });
1399 }
1401 const parentPath = path.dirname(commitPath);
1402 let parent;
1403 try {
1404 parent = fs.statSync(parentPath, { bigint: true });
1405 } catch (error) {
1406 return filesystemFailure('parent-inspection-failed', error, { commitPath, parentPath });
1407 }
1408 if (!parent.isDirectory()) {
1409 return relation('unknown', 'parent-not-directory', { commitPath, parentPath });
1410 }
1411 if (parent.ino === 0n) {
1412 return relation('unknown', 'parent-identity-unavailable', { commitPath, parentPath });
1413 }
1414 return {
1415 status: 'captured',
1416 slot: {
1417 commitPath,
1418 parentPath,
1419 name: path.basename(commitPath),
1420 parentDevice: parent.dev,
1421 parentInode: parent.ino,
1422 },
1423 };
1424}
1426function captureTarget(commitPath) {
1427 let metadata;
1428 try {
1429 // The canonical commit path names the directory entry that rename will
1430 // replace. Never follow a link introduced after canonicalization.
1431 metadata = fs.lstatSync(commitPath, { bigint: true });
1432 } catch (error) {
1433 if (error.code === 'ENOENT') {
1434 return { status: 'captured', target: { kind: 'absent' }, mode: null };
1435 }
1436 return filesystemFailure('target-inspection-failed', error, { commitPath });
1437 }
1439 const type = entryType(metadata);
1440 if (type !== 'file') {
1441 return relation('unsupported', 'target-not-regular-file', { commitPath, entryType: type });
1442 }
1443 const inspected = captureRegularFileHandle(commitPath, metadata, 'target');
1444 if (inspected.status !== 'captured') return inspected;
1445 metadata = inspected.metadata;
1446 return {
1447 status: 'captured',
1448 target: {
1449 kind: 'file',
1450 device: metadata.dev,
1451 inode: metadata.ino,
1452 mode: Number(metadata.mode & 0o777n),
1453 },
1454 mode: Number(metadata.mode & 0o777n),
1455 };
1456}
1458function captureRequestedEntry(requestedPath, policy) {
1459 let metadata;
1460 try {
1461 metadata = fs.lstatSync(requestedPath, { bigint: true });
1462 } catch (error) {
1463 if (error.code === 'ENOENT') {
1464 return { status: 'captured', entry: { kind: 'absent' } };
1465 }
1466 return filesystemFailure('requested-entry-inspection-failed', error, { requestedPath });
1467 }
1468 const type = entryType(metadata);
1469 if (policy === 'regular-or-absent' && type !== 'file') {
1470 return relation('unsupported', type === 'symbolic-link'
1471 ? 'requested-entry-symbolic-link'
1472 : 'requested-entry-not-regular-file', {
1473 requestedPath,
1474 entryType: type,
1475 });
1476 }
1477 if (metadata.ino === 0n) {
1478 return relation('unknown', 'requested-entry-identity-unavailable', { requestedPath });
1479 }
1480 if (policy === 'regular-or-absent') {
1481 const inspected = captureRegularFileHandle(requestedPath, metadata, 'requested-entry');
1482 if (inspected.status !== 'captured') return inspected;
1483 metadata = inspected.metadata;
1484 }
1485 return {
1486 status: 'captured',
1487 entry: {
1488 kind: 'existing',
1489 entryType: type,
1490 device: metadata.dev,
1491 inode: metadata.ino,
1492 // An unlinked symlink's inode can be reused immediately. Its target can
1493 // stay the same while the requested directory entry is a new object.
1494 // Reading the link only changes atime, so these no-follow timestamps
1495 // distinguish that replacement without tracking target-file writes.
1496 ...(type === 'symbolic-link' ? {
1497 changedAtNs: metadata.ctimeNs,
1498 createdAtNs: metadata.birthtimeNs,
1499 } : {}),
1500 },
1501 };
1502}
1504function requestedEntryMatches(left, right) {
1505 if (left.kind !== right.kind) return false;
1506 return left.kind === 'absent'
1507 || (left.entryType === right.entryType
1508 && left.device === right.device
1509 && left.inode === right.inode
1510 && (left.entryType !== 'symbolic-link'
1511 || (left.changedAtNs === right.changedAtNs
1512 && left.createdAtNs === right.createdAtNs)));
1513}
1515/**
1516 * Capture the physical directory-entry slot and target identity used by a
1517 * verified publication. Hard links are intentionally unsupported: replacing
1518 * one name cannot safely update its unknown sibling names.
1519 * `regular-or-absent` additionally rejects requested-entry symlinks and special
1520 * nodes for sidecars whose public name itself must never be followed.
1521 */
1522export function captureAtomicOutput(outputPath, { requestedEntryPolicy = 'followable-alias' } = {}) {
1523 if (!['followable-alias', 'regular-or-absent'].includes(requestedEntryPolicy)) {
1524 return relation('unknown', 'invalid-requested-entry-policy', { requestedEntryPolicy });
1525 }
1526 const requestedPath = path.resolve(outputPath);
1527 const requestedEntry = captureRequestedEntry(requestedPath, requestedEntryPolicy);
1528 if (requestedEntry.status !== 'captured') return requestedEntry;
1529 const slot = captureWriteSlot(requestedPath);
1530 if (slot.status !== 'captured') return slot;
1531 const target = captureTarget(slot.slot.commitPath);
1532 if (target.status !== 'captured') return target;
1533 const confirmedRequestedEntry = captureRequestedEntry(requestedPath, requestedEntryPolicy);
1534 if (confirmedRequestedEntry.status !== 'captured') return confirmedRequestedEntry;
1535 if (!requestedEntryMatches(confirmedRequestedEntry.entry, requestedEntry.entry)) {
1536 return relation('unknown', 'requested-entry-changed-during-inspection', { requestedPath });
1537 }
1538 return {
1539 status: 'captured',
1540 commitPath: slot.slot.commitPath,
1541 mode: target.mode,
1542 snapshot: {
1543 requestedPath,
1544 requestedEntryPolicy,
1545 requestedEntry: requestedEntry.entry,
1546 slot: slot.slot,
1547 target: target.target,
1548 },
1549 };
1550}
1552/** Re-resolve and verify the exact write slot plus its absent/file identity. */
1553export function verifyAtomicOutput(snapshot) {
1554 const currentRequestedEntry = captureRequestedEntry(
1555 snapshot.requestedPath,
1556 snapshot.requestedEntryPolicy || 'followable-alias',
1557 );
1558 if (currentRequestedEntry.status !== 'captured') return currentRequestedEntry;
1559 if (!requestedEntryMatches(currentRequestedEntry.entry, snapshot.requestedEntry)) {
1560 return relation('different', 'requested-entry-changed', {
1561 requestedPath: snapshot.requestedPath,
1562 previousKind: snapshot.requestedEntry.kind === 'absent'
1563 ? 'absent'
1564 : snapshot.requestedEntry.entryType,
1565 currentKind: currentRequestedEntry.entry.kind === 'absent'
1566 ? 'absent'
1567 : currentRequestedEntry.entry.entryType,
1568 });
1569 }
1570 const currentSlot = captureWriteSlot(snapshot.requestedPath);
1571 if (currentSlot.status !== 'captured') return currentSlot;
1572 const previousSlot = snapshot.slot;
1573 if (currentSlot.slot.parentDevice !== previousSlot.parentDevice
1574 || currentSlot.slot.parentInode !== previousSlot.parentInode
1575 || currentSlot.slot.name !== previousSlot.name) {
1576 return relation('different', 'write-slot-changed', {
1577 previousCommitPath: previousSlot.commitPath,
1578 currentCommitPath: currentSlot.slot.commitPath,
1579 });
1580 }
1582 const currentTarget = captureTarget(previousSlot.commitPath);
1583 if (currentTarget.status !== 'captured') return currentTarget;
1584 const previousTarget = snapshot.target;
1585 if (currentTarget.target.kind !== previousTarget.kind) {
1586 return relation('different', 'target-existence-changed', {
1587 commitPath: previousSlot.commitPath,
1588 previousKind: previousTarget.kind,
1589 currentKind: currentTarget.target.kind,
1590 });
1591 }
1592 if (previousTarget.kind === 'file'
1593 && (currentTarget.target.device !== previousTarget.device
1594 || currentTarget.target.inode !== previousTarget.inode)) {
1595 return relation('different', 'target-identity-changed', {
1596 commitPath: previousSlot.commitPath,
1597 });
1598 }
1599 if (previousTarget.kind === 'file'
1600 && currentTarget.target.mode !== previousTarget.mode) {
1601 return relation('different', 'target-mode-changed', {
1602 commitPath: previousSlot.commitPath,
1603 previousMode: previousTarget.mode,
1604 currentMode: currentTarget.target.mode,
1605 });
1606 }
1607 return relation('match', 'atomic-output-match', { commitPath: previousSlot.commitPath });
1608}
1610function parsePublicationRecoveryRecord(buffer) {
1611 if (!Buffer.isBuffer(buffer) || buffer.byteLength > publicationRecoveryRecordMaxBytes) {
1612 return relation('unknown', 'publication-recovery-record-too-large');
1613 }
1614 let parsed;
1615 try {
1616 parsed = JSON.parse(buffer.toString('utf8'));
1617 } catch (error) {
1618 return filesystemFailure('publication-recovery-record-invalid-json', error);
1619 }
1620 const backup = parsed?.backup;
1621 const recovery = parsed?.recovery;
1622 const target = parsed?.target;
1623 const requestedEntry = target?.requestedEntry;
1624 const validEntry = requestedEntry?.kind === 'absent'
1625 || (requestedEntry?.kind === 'existing'
1626 && typeof requestedEntry.entryType === 'string'
1627 && parseDecimal(requestedEntry.device) !== null
1628 && parseDecimal(requestedEntry.inode) !== null
1629 && (requestedEntry.entryType !== 'symbolic-link'
1630 || (parseDecimal(requestedEntry.changedAtNs) !== null
1631 && parseDecimal(requestedEntry.createdAtNs) !== null)));
1632 const valid = parsed?.version === publicationRecoveryRecordVersion
1633 && parsed?.kind === 'archify-retired-output-recovery'
1634 && backup?.name === 'previous'
1635 && typeof backup.sha256 === 'string' && /^[a-f\d]{64}$/i.test(backup.sha256)
1636 && parseSafeInteger(backup.bytes) !== null
1637 && Number.isSafeInteger(backup.mode) && backup.mode >= 0 && backup.mode <= 0o777
1638 && parseDecimal(backup.device) !== null && parseDecimal(backup.inode) !== null
1639 && typeof recovery?.name === 'string' && /^\.archify-remove-[a-f\d]{32}$/.test(recovery.name)
1640 && parseDecimal(recovery.device) !== null
1641 && parseDecimal(recovery.inode) !== null
1642 && typeof target?.requestedPath === 'string' && path.isAbsolute(target.requestedPath)
1643 && typeof target.requestedEntryPolicy === 'string'
1644 && ['followable-alias', 'regular-or-absent'].includes(target.requestedEntryPolicy)
1645 && validEntry
1646 && typeof target.commitPath === 'string' && path.isAbsolute(target.commitPath)
1647 && typeof target.parentPath === 'string' && path.isAbsolute(target.parentPath)
1648 // path-contract-allow: lexical-capability -- A record must exactly decompose the captured canonical target spelling.
1649 && typeof target.name === 'string' && target.name === path.basename(target.commitPath)
1650 // path-contract-allow: lexical-capability -- A record must exactly decompose the captured canonical target spelling.
1651 && target.parentPath === path.dirname(target.commitPath)
1652 && parseDecimal(target.parentDevice) !== null && parseDecimal(target.parentInode) !== null;
1653 if (!valid) return relation('unknown', 'publication-recovery-record-invalid');
1654 return {
1655 status: 'parsed',
1656 record: {
1657 backup: {
1658 ...backup,
1659 device: parseDecimal(backup.device),
1660 inode: parseDecimal(backup.inode),
1661 },
1662 recovery: {
1663 ...recovery,
1664 device: parseDecimal(recovery.device),
1665 inode: parseDecimal(recovery.inode),
1666 },
1667 target: {
1668 ...target,
1669 parentDevice: parseDecimal(target.parentDevice),
1670 parentInode: parseDecimal(target.parentInode),
1671 requestedEntry: requestedEntry.kind === 'absent' ? requestedEntry : {
1672 ...requestedEntry,
1673 device: parseDecimal(requestedEntry.device),
1674 inode: parseDecimal(requestedEntry.inode),
1675 ...(requestedEntry.entryType === 'symbolic-link' ? {
1676 changedAtNs: parseDecimal(requestedEntry.changedAtNs),
1677 createdAtNs: parseDecimal(requestedEntry.createdAtNs),
1678 } : {}),
1679 },
1680 },
1681 },
1682 };
1683}
1685function verifyPublicationRecoveryDirectory(directory, metadata, record) {
1686 let parentMetadata;
1687 try {
1688 parentMetadata = fs.statSync(path.dirname(directory), { bigint: true });
1689 } catch (error) {
1690 return filesystemFailure('publication-recovery-parent-inspection-failed', error, {
1691 recoveryDirectory: directory,
1692 });
1693 }
1694 // path-contract-allow: lexical-capability -- The generated private-directory component is an exact record-bound capability.
1695 if (path.basename(directory) !== record.recovery.name
1696 || !parentMetadata.isDirectory()
1697 || parentMetadata.dev !== record.target.parentDevice
1698 || parentMetadata.ino !== record.target.parentInode) {
1699 return relation('different', 'publication-recovery-directory-not-target-child', {
1700 recoveryDirectory: directory,
1701 expectedParentPath: record.target.parentPath,
1702 });
1703 }
1704 if (metadata.dev !== record.recovery.device || metadata.ino !== record.recovery.inode) {
1705 return relation('different', 'publication-recovery-directory-identity-changed', {
1706 recoveryDirectory: directory,
1707 });
1708 }
1709 return relation('match', 'publication-recovery-directory-match', {
1710 recoveryDirectory: directory,
1711 });
1712}
1714function verifyRetiredPublicationSlot(record) {
1715 const { target } = record;
1716 const slot = captureWriteSlot(target.requestedPath);
1717 if (slot.status !== 'captured') return slot;
1718 // path-contract-allow: lexical-capability -- The recaptured canonical commit spelling must exactly match the recorded capability.
1719 const commitPathChanged = slot.slot.commitPath !== target.commitPath;
1720 // path-contract-allow: lexical-capability -- The recaptured canonical parent spelling must exactly match the recorded capability.
1721 const parentPathChanged = slot.slot.parentPath !== target.parentPath;
1722 if (commitPathChanged
1723 || parentPathChanged
1724 || slot.slot.name !== target.name
1725 || slot.slot.parentDevice !== target.parentDevice
1726 || slot.slot.parentInode !== target.parentInode) {
1727 return relation('different', 'publication-recovery-slot-changed', {
1728 requestedPath: target.requestedPath,
1729 commitPath: target.commitPath,
1730 });
1731 }
1732 try {
1733 fs.lstatSync(target.commitPath, { bigint: true });
1734 return relation('preserved', 'publication-recovery-target-present', {
1735 commitPath: target.commitPath,
1736 });
1737 } catch (error) {
1738 if (error?.code !== 'ENOENT') {
1739 return filesystemFailure('publication-recovery-target-inspection-failed', error, {
1740 commitPath: target.commitPath,
1741 });
1742 }
1743 }
1744 let requested;
1745 try {
1746 requested = fs.lstatSync(target.requestedPath, { bigint: true });
1747 } catch (error) {
1748 if (error?.code !== 'ENOENT') {
1749 return filesystemFailure('publication-recovery-requested-entry-inspection-failed', error, {
1750 requestedPath: target.requestedPath,
1751 });
1752 }
1753 }
1754 if (target.requestedEntry.kind === 'existing'
1755 && target.requestedEntry.entryType === 'symbolic-link') {
1756 if (!requested?.isSymbolicLink()
1757 || requested.dev !== target.requestedEntry.device
1758 || requested.ino !== target.requestedEntry.inode
1759 || requested.ctimeNs !== target.requestedEntry.changedAtNs
1760 || requested.birthtimeNs !== target.requestedEntry.createdAtNs) {
1761 return relation('different', 'publication-recovery-requested-alias-changed', {
1762 requestedPath: target.requestedPath,
1763 });
1764 }
1765 } else if (requested) {
1766 return relation('different', 'publication-recovery-requested-entry-reclaimed', {
1767 requestedPath: target.requestedPath,
1768 });
1769 }
1770 return relation('match', 'publication-recovery-slot-match', {
1771 commitPath: target.commitPath,
1772 });
1773}
1775function releaseRecoveryBindings(result, recordBinding, backupBinding) {
1776 const failures = [];
1777 if (backupBinding) {
1778 const released = releaseRegularFileBinding(backupBinding);
1779 if (released.status !== 'released') failures.push(released.reason);
1780 }
1781 if (recordBinding) {
1782 const released = releaseRegularFileBinding(recordBinding);
1783 if (released.status !== 'released') failures.push(released.reason);
1784 }
1785 if (failures.length === 0) return result;
1786 return {
1787 ...result,
1788 status: 'recovery-required',
1789 releaseState: failures,
1790 };
1791}
1793/**
1794 * Explicitly restore one retired output after a process crash. This is not a
1795 * directory scanner: callers must name the private recovery directory they
1796 * intend to recover. A record is only a claim; the restored byte stream must
1797 * still match its recorded inode, mode, SHA-256 and size, the original parent
1798 * slot and aliases must still resolve identically, and the public name must be
1799 * absent. `linkSync` is deliberately no-clobber, so a later claimant wins.
1800 */
1801export function recoverRetiredPublication(recoveryDirectory) {
1802 const directory = path.resolve(recoveryDirectory);
1803 let directoryMetadata;
1804 try {
1805 directoryMetadata = fs.lstatSync(directory, { bigint: true });
1806 } catch (error) {
1807 if (error?.code === 'ENOENT') {
1808 return relation('absent', 'publication-recovery-already-complete', { recoveryDirectory: directory });
1809 }
1810 return filesystemFailure('publication-recovery-directory-inspection-failed', error, {
1811 recoveryDirectory: directory,
1812 });
1813 }
1814 if (!directoryMetadata.isDirectory()) {
1815 return relation('unsupported', 'publication-recovery-directory-not-directory', {
1816 recoveryDirectory: directory,
1817 entryType: entryType(directoryMetadata),
1818 });
1819 }
1820 if (process.platform !== 'win32' && Number(directoryMetadata.mode & 0o077n) !== 0) {
1821 return relation('unsupported', 'publication-recovery-directory-not-private', {
1822 recoveryDirectory: directory,
1823 });
1824 }
1825 const recordPath = path.join(directory, publicationRecoveryRecordName);
1826 let recordMetadata;
1827 try {
1828 recordMetadata = fs.lstatSync(recordPath, { bigint: true });
1829 } catch (error) {
1830 return filesystemFailure('publication-recovery-record-inspection-failed', error, {
1831 recoveryDirectory: directory,
1832 recoveryFile: recordPath,
1833 });
1834 }
1835 if (!recordMetadata.isFile() || recordMetadata.nlink !== 1n
1836 || recordMetadata.size > BigInt(publicationRecoveryRecordMaxBytes)) {
1837 return relation('unsupported', 'publication-recovery-record-not-private-regular-file', {
1838 recoveryDirectory: directory,
1839 recoveryFile: recordPath,
1840 });
1841 }
1842 const capturedRecord = captureRegularFileBinding(recordPath, {
1843 subject: 'publication-recovery-record',
1844 expectedLinks: 1,
1845 includeContent: true,
1846 });
1847 if (capturedRecord.status !== 'captured') return capturedRecord;
1848 const parsed = parsePublicationRecoveryRecord(capturedRecord.content.buffer);
1849 if (parsed.status !== 'parsed') {
1850 return releaseRecoveryBindings(parsed, capturedRecord.binding);
1851 }
1852 const recoveryDirectoryState = verifyPublicationRecoveryDirectory(
1853 directory,
1854 directoryMetadata,
1855 parsed.record,
1856 );
1857 if (recoveryDirectoryState.status !== 'match') {
1858 return releaseRecoveryBindings({
1859 ...recoveryDirectoryState,
1860 recoveryDirectory: directory,
1861 recoveryFile: recordPath,
1862 }, capturedRecord.binding);
1863 }
1864 const slot = verifyRetiredPublicationSlot(parsed.record);
1865 if (slot.status !== 'match') {
1866 return releaseRecoveryBindings({
1867 ...slot,
1868 recoveryDirectory: directory,
1869 recoveryFile: recordPath,
1870 }, capturedRecord.binding);
1871 }
1872 const backupPath = path.join(directory, parsed.record.backup.name);
1873 const capturedBackup = captureRegularFileBinding(backupPath, {
1874 subject: 'publication-recovery-backup',
1875 expectedSha256: parsed.record.backup.sha256,
1876 expectedBytes: parsed.record.backup.bytes,
1877 expectedMode: parsed.record.backup.mode,
1878 expectedIdentity: {
1879 device: parsed.record.backup.device,
1880 inode: parsed.record.backup.inode,
1881 },
1882 expectedLinks: 1,
1883 });
1884 if (capturedBackup.status !== 'captured') {
1885 return releaseRecoveryBindings({
1886 ...capturedBackup,
1887 recoveryDirectory: directory,
1888 recoveryFile: backupPath,
1889 }, capturedRecord.binding);
1890 }
1891 // This second check narrows the remaining path-based link window. It cannot
1892 // make `linkSync` descriptor-bound, but it avoids linking after an ordinary
1893 // observed parent, alias, target, or backup change.
1894 const beforeLinkSlot = verifyRetiredPublicationSlot(parsed.record);
1895 const beforeLinkBackup = verifyRegularFileBinding(capturedBackup.binding, {
1896 filePath: backupPath,
1897 expectedLinks: 1,
1898 });
1899 if (beforeLinkSlot.status !== 'match' || beforeLinkBackup.status !== 'match') {
1900 return releaseRecoveryBindings({
1901 status: beforeLinkSlot.status === 'match' ? beforeLinkBackup.status : beforeLinkSlot.status,
1902 reason: {
1903 code: 'publication-recovery-pre-link-verification-failed',
1904 slotState: beforeLinkSlot.reason,
1905 backupState: beforeLinkBackup.reason,
1906 },
1907 recoveryDirectory: directory,
1908 recoveryFile: backupPath,
1909 }, capturedRecord.binding, capturedBackup.binding);
1910 }
1911 try {
1912 fs.linkSync(backupPath, parsed.record.target.commitPath);
1913 } catch (error) {
1914 return releaseRecoveryBindings(relation(
1915 error?.code === 'EEXIST' ? 'preserved' : 'unknown',
1916 error?.code === 'EEXIST'
1917 ? 'publication-recovery-target-created'
1918 : 'publication-recovery-link-failed',
1919 {
1920 recoveryDirectory: directory,
1921 recoveryFile: backupPath,
1922 commitPath: parsed.record.target.commitPath,
1923 ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
1924 },
1925 ), capturedRecord.binding, capturedBackup.binding);
1926 }
1927 const publicState = verifyRegularFileBinding(capturedBackup.binding, {
1928 filePath: parsed.record.target.commitPath,
1929 expectedLinks: 2,
1930 });
1931 const backupState = verifyRegularFileBinding(capturedBackup.binding, {
1932 filePath: backupPath,
1933 expectedLinks: 2,
1934 });
1935 if (publicState.status !== 'match' || backupState.status !== 'match') {
1936 // We may have introduced an alias to a pathname that was swapped after the
1937 // pre-link checks. Remove only that alias when it is still a verified
1938 // two-link pair; otherwise preserve every uncertain name for inspection.
1939 const linkedAliasCleanup = quarantineRemoveLinkedRegularFileAlias(
1940 backupPath,
1941 parsed.record.target.commitPath,
1942 { subject: 'publication-recovery-unverified-link' },
1943 );
1944 return releaseRecoveryBindings(relation('recovery-required', 'publication-recovery-link-verification-failed', {
1945 recoveryDirectory: directory,
1946 recoveryFile: backupPath,
1947 commitPath: parsed.record.target.commitPath,
1948 publicState: publicState.reason,
1949 backupState: backupState.reason,
1950 linkedAliasCleanup: linkedAliasCleanup.reason,
1951 }), capturedRecord.binding, capturedBackup.binding);
1952 }
1953 const backupRemoval = quarantineRemoveRegularFileBinding(capturedBackup.binding, backupPath, {
1954 subject: 'publication-recovery-backup',
1955 expectedLinks: 2,
1956 });
1957 if (backupRemoval.status !== 'removed') {
1958 return releaseRecoveryBindings({
1959 ...backupRemoval,
1960 status: 'recovered-with-warning',
1961 recoveryDirectory: directory,
1962 recoveryFile: backupPath,
1963 target: parsed.record.target.commitPath,
1964 }, capturedRecord.binding, capturedBackup.binding);
1965 }
1966 const recordCleanup = discardPublicationRecoveryRecord({
1967 recordPath,
1968 binding: capturedRecord.binding,
1969 });
1970 const directoryCleanup = removeEmptyQuarantine(directory);
1971 const result = {
1972 status: recordCleanup || directoryCleanup ? 'recovered-with-warning' : 'recovered',
1973 reason: {
1974 code: 'publication-recovery-restored',
1975 recoveryDirectory: directory,
1976 commitPath: parsed.record.target.commitPath,
1977 },
1978 ...(recordCleanup ? { recoveryRecordState: recordCleanup.reason } : {}),
1979 ...(directoryCleanup ? { directoryCleanupState: directoryCleanup.reason } : {}),
1980 };
1981 return releaseRecoveryBindings(result, undefined, capturedBackup.binding);
1982}