blob: 04a21a9873cf599a5ecbd9c868f28ee13ceca4e5 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// packages/runtime/src/file-stable-write.ts
// The fd-pinned mutation primitive enforcing the filesystem-authority contract
// (#2600). A path-based write re-opens the pathname after every check, so a
// swap between validation and the open diverts the write onto the replacement
// and the post-write check can only *report* the corruption afterwards. This
// module removes that window structurally: the approved object is opened once,
// its identity is validated on the descriptor itself (fstat, not a second
// pathname lookup), and the read/transform/write all run through that same
// descriptor — a path swap mid-operation cannot redirect the bytes.
//
// Missing targets are created with `wx` (exclusive): if the path appeared
// between authorisation and the open, EEXIST is reported as `path_changed`
// rather than truncating whatever landed there.
//
// The descriptor is validated BEFORE any truncation: an 'r+' open does not
// truncate, so a rejected validation leaves the file byte-for-byte intact —
// unlike a plain 'w' open, which truncates as part of opening.
import { randomUUID } from 'node:crypto';
import {
link,
lstat,
open,
readlink,
rename,
stat,
symlink,
unlink,
type FileHandle,
} from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname, join } from 'node:path';
import type { FilesystemTargetIdentity } from './filesystem-authority.js';
/** The failure modes this primitive can report. */
export type StableWriteErrorCode = 'path_changed' | 'outcome_unknown' | 'is_directory';
export class StableWriteFailure extends Error {
constructor(
readonly code: StableWriteErrorCode,
message: string,
) {
super(message);
this.name = 'StableWriteFailure';
}
}
function pathChanged(message: string): StableWriteFailure {
return new StableWriteFailure('path_changed', message);
}
/**
* Open the approved target and validate its identity on the descriptor.
*
* - Existing target (approvedIdentity present): `open(path, 'r+' | O_NOFOLLOW)`
* — POSIX refuses a final symlink at open time, and 'r+' does not truncate,
* so a failed identity check leaves the file untouched. Windows has no
* O_NOFOLLOW; the link is detected with an lstat just before the open and
* the residual window is closed by the identity comparison on the fd.
* - Existing target without an identity (an 'unchecked' caller, targetType is
* a concrete type): same open, no identity comparison — there is nothing to
* compare, and the caller declared it does not participate in CAS.
* - Approved-missing target (approvedIdentity undefined, targetType
* 'missing'): `open(path, 'wx')` — atomic create-if-absent. EEXIST means
* something appeared in the gap.
*/
export async function openStableTarget(input: {
path: string;
approvedIdentity: FilesystemTargetIdentity | undefined;
targetType?: 'file' | 'directory' | 'symlink' | 'other' | 'missing';
}): Promise<FileHandle> {
if (input.approvedIdentity) {
const handle = await openExistingNoTruncate(input.path);
// The compare in compare-and-update, performed on the descriptor itself.
const metadata = await handle.stat({ bigint: true });
if (
String(metadata.dev) !== input.approvedIdentity.dev ||
String(metadata.ino) !== input.approvedIdentity.ino
) {
await handle.close();
throw pathChanged('The approved filesystem target changed before execution.');
}
return handle;
}
if (input.targetType !== undefined && input.targetType !== 'missing') {
// An existing target with no identity: the caller explicitly opted out of
// CAS (#3484). Open without truncation but perform no comparison — the
// caller's own absence of a T0 snapshot is the contract.
return openExistingNoTruncate(input.path);
}
try {
return await open(input.path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
throw pathChanged('A file appeared at the approved missing target; re-read before writing.');
}
throw error;
}
}
/** Open an existing target for read-write without truncating it. */
async function openExistingNoTruncate(path: string): Promise<FileHandle> {
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
if (process.platform === 'win32') {
const entry = await lstat(path).catch(() => null);
if (entry?.isSymbolicLink()) {
throw pathChanged(
'The approved filesystem target is a symbolic link; refusing to follow it.',
);
}
}
try {
return await open(path, constants.O_RDWR | noFollow);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ELOOP' || code === 'ENOTDIR' || code === 'ENOENT') {
throw pathChanged('The approved filesystem target changed before execution.');
}
if (code === 'EACCES' || code === 'EPERM') {
// A write-only target (e.g. mode 0o222) refuses 'r+' but is still a
// legitimate mutation target — unlink semantics need no read
// permission. Retry write-only (still no truncate: identity is
// validated on the descriptor before writeThroughHandle truncates).
// The pinned read of the previous content will fail and the caller
// reports 'unknown' (no diff), which is the pre-existing behaviour.
try {
return await open(path, constants.O_WRONLY | noFollow);
} catch (retry) {
const retryCode = (retry as NodeJS.ErrnoException).code;
if (retryCode === 'ELOOP' || retryCode === 'ENOTDIR' || retryCode === 'ENOENT') {
throw pathChanged('The approved filesystem target changed before execution.');
}
throw retry;
}
}
throw error;
}
}
/**
* Write `content` through the pinned descriptor. The truncation happens here,
* only after the identity was validated on the fd. Write-step failures
* (ENOSPC/EIO/EDQUOT/EFBIG) can leave the file truncated or half-written, so
* they surface as `outcome_unknown` — the file's state is genuinely unknown.
*/
export async function writeThroughHandle(handle: FileHandle, content: string): Promise<void> {
try {
await handle.truncate(0);
// Position 0 explicitly: a prior readFile leaves the fd position at EOF,
// and a positionless write would create a NUL-prefixed sparse file.
await handle.write(content, 0, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOSPC' || code === 'EIO' || code === 'EDQUOT' || code === 'EFBIG') {
throw new StableWriteFailure(
'outcome_unknown',
'The write started but failed partway; the file may be truncated. ' +
'Re-read the file before writing to it again.',
);
}
throw error;
}
}
/**
* Read-modify-write entirely through one descriptor. Transform errors happen
* before any truncation and propagate unchanged (nothing was written). An
* unchanged result skips the write entirely, preserving mtime for no-ops.
*/
export async function readModifyWriteThroughHandle(
handle: FileHandle,
transform: (existing: string) => string,
): Promise<string> {
const existing = await handle.readFile('utf8');
const replacement = transform(existing);
if (replacement !== existing) {
await writeThroughHandle(handle, replacement);
}
return replacement;
}
/**
* After writing through the handle, describe the host-visible outcome: if the
* path no longer resolves to the descriptor's inode, the write went to an
* orphaned inode and the visible file is the replacement — an unknown outcome.
* `undefined` means the path still matches and the write is host-visible.
*/
export async function hostVisibilityAfterWrite(
path: string,
handle: FileHandle,
): Promise<StableWriteFailure | undefined> {
const written = await handle.stat({ bigint: true });
let current: { dev: bigint; ino: bigint };
try {
current = await stat(path, { bigint: true });
} catch {
return new StableWriteFailure(
'outcome_unknown',
'The target disappeared after the write; the outcome on disk is unknown.',
);
}
if (String(current.dev) !== String(written.dev) || String(current.ino) !== String(written.ino)) {
return new StableWriteFailure(
'outcome_unknown',
'The target was replaced during the write; the outcome on disk is unknown.',
);
}
return undefined;
}
/**
* Compare-and-delete for directory entries (#2600). POSIX has no atomic
* compare-and-unlink, so a bare `unlink(path)` can remove a replacement
* installed between the identity check and the unlink — and report success.
* Instead, atomically grab whatever the path currently names by renaming it to
* a private tombstone in the same directory, verify the tombstone carries the
* approved identity, and only then unlink the tombstone. A mismatch means a
* replacement was installed in the window: it is moved back to the path
* WITHOUT clobbering anything that reoccupied it (no-replace restore) and the
* operation reports `path_changed`.
*
* rename(2) moves the directory entry itself, so this works for regular files
* and symlinks alike, needs no permission on the file (only write+execute on
* the parent directory, exactly like unlink), and never follows a symlink.
* Directories are refused — up front when visible, and again after the
* capture when a directory races into the window — because a directory cannot
* be unlinked, only recursively removed: a different operation entirely.
*/
export async function compareAndDeleteEntry(input: {
path: string;
approvedIdentity: FilesystemTargetIdentity | undefined;
}): Promise<void> {
// Fast refusal when the directory is visible up front (#2600 review): a
// rename into the tombstone succeeds for directories, but the tombstone
// unlink cannot (EISDIR/EPERM), which would hide the directory under a
// stray name instead of failing cleanly. The post-capture check below is
// the enforcement; this pre-check just avoids moving anything.
const entryBefore = await lstat(input.path);
if (entryBefore.isDirectory()) {
throw new StableWriteFailure(
'is_directory',
'Refusing to delete a directory through the entry-delete path.',
);
}
const tombstone = join(dirname(input.path), `.maka-pending-delete-${randomUUID()}`);
// Atomically capture whatever entry the path names right now.
await rename(input.path, tombstone);
await deleteCapturedTombstone(tombstone, input.path, input.approvedIdentity);
}
/**
* Verify and delete the entry held on the tombstone, restoring it on any
* mismatch. The capture rename in the caller is the atomic step, so this is
* where enforcement lives (#2600 review): the captured entry's TYPE is checked
* before anything else — a directory that raced in after the caller's pre-check
* is renamed straight back rather than identity-compared (link() cannot restore
* a directory, so the mismatch path would strand it on the tombstone).
*
* @internal Exported for the forced-directory-in-tombstone regression test.
*/
export async function deleteCapturedTombstone(
tombstone: string,
path: string,
approvedIdentity: FilesystemTargetIdentity | undefined,
): Promise<void> {
const captured = await lstat(tombstone).catch(() => null);
if (captured?.isDirectory()) {
// A directory raced into the window between the caller's pre-check and
// the capture. Restore it by rename — the only mechanism that moves a
// directory — before any identity comparison. POSIX self-protects most
// reoccupations (dir → existing non-directory fails ENOTDIR); if even
// that fails, preserve the tombstone and report the location.
try {
await rename(tombstone, path);
} catch {
throw new StableWriteFailure(
'outcome_unknown',
`A directory was captured and could not be restored; it is preserved at ${tombstone}.`,
);
}
throw new StableWriteFailure(
'is_directory',
'Refusing to delete a directory through the entry-delete path; it was restored to its original location.',
);
}
if (approvedIdentity) {
const entry = await lstat(tombstone, { bigint: true }).catch(() => null);
const matches =
entry !== null &&
String(entry.dev) === approvedIdentity.dev &&
String(entry.ino) === approvedIdentity.ino;
if (!matches) {
// A replacement was installed after the check. Restore it without
// clobbering: if the path was reoccupied in the window, the restore
// itself throws outcome_unknown and the tombstone is preserved.
await restoreTombstoneNoReplace(tombstone, path);
throw pathChanged(
'The approved filesystem target changed before the delete; the replacement was restored. Re-check the directory before deleting again.',
);
}
}
// The tombstone is a private unpredictable name: nothing else contends
// with this unlink.
try {
await unlink(tombstone);
} catch {
// The approved entry was moved but not removed: the delete's outcome on
// disk is genuinely unknown, and the tombstone preserves the content.
throw new StableWriteFailure(
'outcome_unknown',
`The delete may not have completed; the entry is preserved at ${tombstone}.`,
);
}
}
/**
* Move the tombstone entry back to its original path WITHOUT overwriting
* whatever may have reoccupied the path while the entry was captured (#2600
* review: a plain rename would atomically delete the new occupant — the exact
* class of replacement loss this module exists to prevent). Node exposes no
* RENAME_NOREPLACE, so the no-replace mechanism depends on the entry type:
*
* - Regular file: `link()` is natively no-replace (EEXIST when the destination
* exists) and links the very inode the tombstone holds.
* - Symlink: `link()` is unusable — POSIX leaves its treatment of a symlink
* source implementation-defined, and darwin dereferences it, which would
* plant a regular-file alias of the TARGET at the path (a foreign entry any
* later read/write silently edits). The link is instead recreated with
* `symlink(readlink(...))`, which is also natively no-replace and round-trips
* the target string exactly; the recreated link is a new inode, which is
* immaterial for a delete this call is refusing anyway.
*
* On EEXIST — or any creation failure — the tombstone is preserved and the
* failure reported as `outcome_unknown` with the location, so nothing is ever
* lost.
*
* @internal Exported for the no-replace restore regression tests.
*/
export async function restoreTombstoneNoReplace(tombstone: string, path: string): Promise<void> {
const captured = await lstat(tombstone).catch(() => null);
if (captured === null) {
throw new StableWriteFailure(
'outcome_unknown',
`The captured entry could not be read; it is preserved at ${tombstone}.`,
);
}
if (captured.isSymbolicLink()) {
const target = await readlink(tombstone);
try {
await symlink(target, path);
} catch {
// EEXIST: the path was reoccupied after the capture (or symlink creation
// failed). Preserving the tombstone is the only non-destructive option.
throw new StableWriteFailure(
'outcome_unknown',
`The original path was reoccupied; the captured symlink is preserved at ${tombstone}.`,
);
}
// The link semantics are restored at the path; dropping the old link name
// is best-effort — a failure leaves a stray name, never data loss.
await unlink(tombstone).catch(() => {});
return;
}
try {
await link(tombstone, path);
} catch {
// EEXIST: the path was reoccupied after the capture. Any other failure
// (entry type / filesystem without hardlink support): preserving the
// tombstone is the only non-destructive option. Either way nothing is
// lost — the captured entry survives at the tombstone.
throw new StableWriteFailure(
'outcome_unknown',
`The original path was reoccupied; the captured entry is preserved at ${tombstone}.`,
);
}
// The restored entry must be the very inode the tombstone holds. A mismatch
// means the path no longer carries what this call created — most plausibly a
// concurrent rename placed a foreign entry there — and unlinking it would
// destroy third-party data (the exact loss class this module prevents).
// Touch nothing and report both locations.
const restored = await lstat(path, { bigint: true }).catch(() => null);
const held = await lstat(tombstone, { bigint: true }).catch(() => null);
if (
restored === null ||
held === null ||
restored.ino !== held.ino ||
restored.dev !== held.dev
) {
throw new StableWriteFailure(
'outcome_unknown',
`The entry could not be restored reliably; it is preserved at ${tombstone} (path now holds ${path}).`,
);
}
// The entry is verified at the path; dropping the tombstone name is
// best-effort — a failure here leaves a stray name, never data loss, and
// must not turn a successful restore into a reported failure.
await unlink(tombstone).catch(() => {});
}