blob: 80fa75fad9257e63250468b53764c02353f0fd12 [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.
*/
// Deterministic race tests for the fd-pinned mutation primitive (#2600).
// The pinning is the load-bearing defence: once the approved object is open
// and validated on the descriptor, a path swap cannot redirect the write —
// these tests prove it by swapping the path between validation and the write
// and asserting the bytes landed on the original inode, never the replacement.
import assert from 'node:assert/strict';
import { mkdtemp, readFile, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, test } from 'node:test';
import {
compareAndDeleteEntry,
deleteCapturedTombstone,
hostVisibilityAfterWrite,
openStableTarget,
restoreTombstoneNoReplace,
writeThroughHandle,
type StableWriteFailure,
} from '../file-stable-write.js';
const cleanup: string[] = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
async function temporaryDirectory(prefix: string): Promise<string> {
const path = await realpath(await mkdtemp(join(tmpdir(), prefix)));
cleanup.push(path);
return path;
}
async function captureIdentity(path: string): Promise<{ dev: string; ino: string }> {
const { stat } = await import('node:fs/promises');
const s = await stat(path, { bigint: true });
return { dev: String(s.dev), ino: String(s.ino) };
}
describe('fd-pinned mutation primitive', () => {
test('a write survives a path swap between validation and the write', async () => {
const cwd = await temporaryDirectory('maka-pin-swap-');
const target = join(cwd, 'file.txt');
const replacement = join(cwd, 'replacement.txt');
await writeFile(target, 'original', 'utf8');
await writeFile(replacement, 'replacement-body', 'utf8');
const identity = await captureIdentity(target);
// The deterministic race: open + validate on the descriptor, THEN swap
// the path, THEN write. A path-based write would land on the replacement;
// the pinned write cannot.
const handle = await openStableTarget({ path: target, approvedIdentity: identity });
try {
await rename(replacement, target); // the swap happens here
await writeThroughHandle(handle, 'pinned-content');
// The bytes went to the pinned inode: reading back through the same
// descriptor shows the new content.
assert.equal(await handle.readFile('utf8'), 'pinned-content');
// The replacement file (now at the path) is untouched.
assert.equal(await readFile(target, 'utf8'), 'replacement-body');
// Host visibility honestly reports the orphaned write.
const visibility = await hostVisibilityAfterWrite(target, handle);
assert.equal(visibility?.code, 'outcome_unknown');
} finally {
await handle.close();
}
});
test('a failed validation leaves the file byte-for-byte intact', async () => {
const cwd = await temporaryDirectory('maka-pin-reject-');
const target = join(cwd, 'file.txt');
const replacement = join(cwd, 'replacement.txt');
await writeFile(target, 'original', 'utf8');
await writeFile(replacement, 'replacement-body', 'utf8');
const identity = await captureIdentity(target);
// Swap before the open: the descriptor's inode will not match the approved
// identity, and — unlike a 'w' open — the rejected 'r+' never truncated it.
await rename(replacement, target);
await assert.rejects(
openStableTarget({ path: target, approvedIdentity: identity }),
(error: StableWriteFailure) => error.code === 'path_changed',
);
assert.equal(await readFile(target, 'utf8'), 'replacement-body');
});
test('an approved-missing target rejects a file that appeared in the gap', async () => {
const cwd = await temporaryDirectory('maka-pin-wx-');
const target = join(cwd, 'file.txt');
// The gap: the target was approved as missing, then something created it.
await writeFile(target, 'external-content', 'utf8');
await assert.rejects(
openStableTarget({ path: target, approvedIdentity: undefined }),
(error: StableWriteFailure) => error.code === 'path_changed',
);
// The interloper's content was never truncated.
assert.equal(await readFile(target, 'utf8'), 'external-content');
});
test('an approved-missing target is created exclusively', async () => {
const cwd = await temporaryDirectory('maka-pin-create-');
const target = join(cwd, 'file.txt');
const handle = await openStableTarget({ path: target, approvedIdentity: undefined });
try {
await writeThroughHandle(handle, 'created');
} finally {
await handle.close();
}
assert.equal(await readFile(target, 'utf8'), 'created');
});
});
describe('compare-and-delete (tombstone verification)', () => {
// POSIX has no atomic compare-and-unlink, so delete cannot PREVENT a
// same-directory swap — but the tombstone rename makes it atomic to CAPTURE
// whatever the path names, verify it, and RESTORE a replacement instead of
// silently deleting it (#2600 review: "delete can remove replacement").
test('removes the approved entry', async () => {
const cwd = await temporaryDirectory('maka-delete-plain-');
const target = join(cwd, 'file.txt');
await writeFile(target, 'bye', 'utf8');
const identity = await captureIdentity(target);
await compareAndDeleteEntry({ path: target, approvedIdentity: identity });
const { readdir } = await import('node:fs/promises');
assert.deepEqual(await readdir(cwd), []); // gone, and no tombstone leaked
});
test('restores a replacement installed after the check instead of deleting it', async () => {
const cwd = await temporaryDirectory('maka-delete-swap-');
const target = join(cwd, 'file.txt');
const replacement = join(cwd, 'replacement.txt');
await writeFile(target, 'approved', 'utf8');
await writeFile(replacement, 'replacement-body', 'utf8');
const identity = await captureIdentity(target);
// The race: after the check captured the approved identity, an external
// process renames a replacement over the path.
await rename(replacement, target);
await assert.rejects(
compareAndDeleteEntry({ path: target, approvedIdentity: identity }),
(error: StableWriteFailure) => error.code === 'path_changed',
);
// The replacement was restored, not deleted — content intact at the path.
assert.equal(await readFile(target, 'utf8'), 'replacement-body');
const { readdir } = await import('node:fs/promises');
assert.deepEqual(await readdir(cwd), ['file.txt']); // no tombstone leaked
});
test('deletes a symlink entry by its own identity without following it', async () => {
const cwd = await temporaryDirectory('maka-delete-symlink-');
const pointed = join(cwd, 'pointed.txt');
const link = join(cwd, 'link.txt');
await writeFile(pointed, 'keep', 'utf8');
await symlink(pointed, link);
const { lstat } = await import('node:fs/promises');
const meta = await lstat(link, { bigint: true });
const identity = { dev: String(meta.dev), ino: String(meta.ino) };
await compareAndDeleteEntry({ path: link, approvedIdentity: identity });
await assert.rejects(readFile(link, 'utf8'), { code: 'ENOENT' }); // link gone
assert.equal(await readFile(pointed, 'utf8'), 'keep'); // target untouched
});
// #2600 review P1: after the tombstone captured replacement C, another
// process created B at the original path; a rename-based restore would
// atomically overwrite and DELETE B — the exact loss class this module
// prevents. The no-replace restore must preserve both.
test('a no-replace restore preserves a path reoccupied during the capture', async () => {
const cwd = await temporaryDirectory('maka-delete-reoccupied-');
const target = join(cwd, 'file.txt');
const { writeFile: wf } = await import('node:fs/promises');
// C sits on the tombstone; B has reoccupied the original path.
const tombstone = join(cwd, 'tombstone');
await wf(tombstone, 'captured-C', 'utf8');
await wf(target, 'newcomer-B', 'utf8');
await assert.rejects(
restoreTombstoneNoReplace(tombstone, target),
(error: StableWriteFailure) => error.code === 'outcome_unknown',
);
// Both survive, byte-for-byte, at their own names.
assert.equal(await readFile(target, 'utf8'), 'newcomer-B');
assert.equal(await readFile(tombstone, 'utf8'), 'captured-C');
});
test('a no-replace restore moves the captured entry back when the path is free', async () => {
const cwd = await temporaryDirectory('maka-delete-restore-');
const target = join(cwd, 'file.txt');
const { writeFile: wf, readdir } = await import('node:fs/promises');
const tombstone = join(cwd, 'tombstone');
await wf(tombstone, 'captured', 'utf8');
await restoreTombstoneNoReplace(tombstone, target);
assert.equal(await readFile(target, 'utf8'), 'captured');
assert.deepEqual(await readdir(cwd), ['file.txt']); // no tombstone left
});
// #2600 review P2: renaming a directory into the tombstone succeeds while
// the tombstone unlink cannot (EISDIR/EPERM) — the directory would vanish
// from its path and hide under the tombstone name. Reject up front instead.
test('rejects a directory before touching it', async () => {
const cwd = await temporaryDirectory('maka-delete-directory-');
const dir = join(cwd, 'subdir');
const { mkdir, readdir } = await import('node:fs/promises');
await mkdir(dir);
const { stat: st } = await import('node:fs/promises');
const meta = await st(dir, { bigint: true });
await assert.rejects(
compareAndDeleteEntry({
path: dir,
approvedIdentity: { dev: String(meta.dev), ino: String(meta.ino) },
}),
(error: StableWriteFailure) => error.code === 'is_directory',
);
// The directory is untouched at its original path — not hidden anywhere.
assert.deepEqual(await readdir(cwd), ['subdir']);
});
// #2600 review: the pre-check is not an enforcement point — a directory can
// race into the window between it and the capture. The enforcement lives
// after the capture: the tombstone's type is checked and a directory is
// renamed straight back BEFORE any identity comparison (link() cannot
// restore a directory, so the mismatch path would strand it on the tombstone
// with the path left empty). Force the directory into the tombstone directly.
test('a directory that raced into the capture is renamed back, not stranded', async () => {
const cwd = await temporaryDirectory('maka-delete-dirrace-');
const dir = join(cwd, 'raced-in');
const {
mkdir,
readdir,
rename: mv,
rm,
stat: st,
writeFile: wf,
} = await import('node:fs/promises');
// The delete was approved against a regular file at this path.
await wf(dir, 'x', 'utf8');
const meta = await st(dir, { bigint: true });
const approvedIdentity = { dev: String(meta.dev), ino: String(meta.ino) };
// The race: an external process removes the file and puts a directory at
// the path — after the caller's pre-check, before the capture.
await rm(dir);
await mkdir(dir);
// The production caller's capture rename grabs whatever is at the path,
// which is now the directory, onto a fresh tombstone name.
const tombstone = join(cwd, 'captured');
await mv(dir, tombstone);
await assert.rejects(
deleteCapturedTombstone(tombstone, dir, approvedIdentity),
(error: StableWriteFailure) => error.code === 'is_directory',
);
// The directory is back at its original path; nothing is stranded.
assert.deepEqual(await readdir(cwd), ['raced-in']);
});
// #2600 review: on darwin, link() dereferences a symlink source, so a
// link-based restore of a captured symlink would plant a regular-file alias
// of the TARGET at the path — a foreign entry later reads/writes silently
// edit. The restore must recreate the symlink itself; the path may never be
// left holding a foreign regular-file entry.
test('restores a swapped-in symlink as a symlink, never a foreign regular file', async () => {
const cwd = await temporaryDirectory('maka-delete-linkswap-');
const target = join(cwd, 'link.txt');
const pointedA = join(cwd, 'pointed-a.txt');
const pointedC = join(cwd, 'pointed-c.txt');
const { lstat: lst, readdir, symlink: lnsym, writeFile: wf } = await import('node:fs/promises');
await wf(pointedA, 'approved-target', 'utf8');
await wf(pointedC, 'replacement-target', 'utf8');
await lnsym(pointedA, target); // the approved entry A
const meta = await lst(target, { bigint: true });
const approvedIdentity = { dev: String(meta.dev), ino: String(meta.ino) };
// The race: a different symlink C is swapped over the path.
const replacement = join(cwd, 'replacement-link');
await lnsym(pointedC, replacement);
await rename(replacement, target);
await assert.rejects(
compareAndDeleteEntry({ path: target, approvedIdentity }),
(error: StableWriteFailure) => error.code === 'path_changed',
);
// The path holds a SYMLINK pointing where C pointed — not a regular-file
// alias of C's target, and not the approved A either.
const restored = await lst(target);
assert.equal(restored.isSymbolicLink(), true, 'path must hold a symlink, not a regular file');
const { readlink } = await import('node:fs/promises');
assert.equal(await readlink(target), pointedC);
// No tombstone leaked; the approved target's content is untouched.
assert.deepEqual((await readdir(cwd)).sort(), ['link.txt', 'pointed-a.txt', 'pointed-c.txt']);
assert.equal(await readFile(pointedA, 'utf8'), 'approved-target');
});
});