fix(mosaic): hold brain migration destinations by descriptor
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
@@ -137,6 +138,7 @@ interface BrainStoreModule {
|
||||
plan: MigrationPlan,
|
||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||
brainRoot: string,
|
||||
hooks?: { readonly beforeDestinationWrite?: (destination: string) => void },
|
||||
): MigrationResult;
|
||||
evaluateBrainDoctor(
|
||||
observation: BrainDoctorObservation,
|
||||
@@ -764,6 +766,47 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
},
|
||||
);
|
||||
|
||||
it('holds the destination directory while a concurrent actor replaces its ancestor with a symlink', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 destination descriptor race');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
const brainRoot = join(root, 'brain');
|
||||
const outside = join(root, 'outside');
|
||||
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
mkdirSync(join(brainRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
mkdirSync(outside);
|
||||
const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'lane state\n');
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
let swapped = false;
|
||||
let publishCalls = 0;
|
||||
|
||||
const result = sut.migrateBrainState(
|
||||
plan,
|
||||
(): MigrationPublishEvidence => {
|
||||
publishCalls += 1;
|
||||
return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true };
|
||||
},
|
||||
brainRoot,
|
||||
{
|
||||
beforeDestinationWrite: (): void => {
|
||||
if (swapped) return;
|
||||
swapped = true;
|
||||
renameSync(join(brainRoot, 'lanes', 'lane-a'), join(brainRoot, 'lanes', 'lane-a-held'));
|
||||
symlinkSync(outside, join(brainRoot, 'lanes', 'lane-a'));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(readFileSync(source, 'utf8')).toBe('lane state\n');
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(publishCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses nested symlink destinations without copying a migration outside the brain', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 migration destination no-follow');
|
||||
const root = tempRoot();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { platform } from 'node:os';
|
||||
import { z } from 'zod';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
|
||||
@@ -154,6 +155,10 @@ export interface MigrationPublishEvidence {
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
export interface MigrationHooks {
|
||||
readonly beforeDestinationWrite?: (destination: string) => void;
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
readonly status: 'migrated' | 'reported' | 'failed';
|
||||
readonly migrated: readonly MigrationCandidate[];
|
||||
@@ -771,6 +776,61 @@ function assertSafeDestinationAncestors(root: string, destination: string): void
|
||||
}
|
||||
}
|
||||
|
||||
function procDescriptorPath(descriptor: number, name: string): string {
|
||||
return `/proc/self/fd/${descriptor}/${name}`;
|
||||
}
|
||||
|
||||
function openDirectorySecure(directory: string): { readonly fd: number; readonly chain: number[] } {
|
||||
if (platform() !== 'linux')
|
||||
throw new Error('secure migration requires Linux descriptor traversal');
|
||||
const chain: number[] = [];
|
||||
try {
|
||||
let descriptor = openSync(
|
||||
sep,
|
||||
fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW,
|
||||
);
|
||||
chain.push(descriptor);
|
||||
for (const component of resolve(directory).split(sep).filter(Boolean)) {
|
||||
descriptor = openSync(
|
||||
procDescriptorPath(descriptor, component),
|
||||
fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW,
|
||||
);
|
||||
if (!fstatSync(descriptor).isDirectory()) {
|
||||
throw new Error('migration destination ancestor is not a directory');
|
||||
}
|
||||
chain.push(descriptor);
|
||||
}
|
||||
return { fd: descriptor, chain };
|
||||
} catch (error: unknown) {
|
||||
for (const descriptor of chain.reverse()) closeSync(descriptor);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDirectorySecure(chain: readonly number[]): void {
|
||||
for (const descriptor of [...chain].reverse()) closeSync(descriptor);
|
||||
}
|
||||
|
||||
function verifyHeldDestinationVisible(
|
||||
heldPath: string,
|
||||
visiblePath: string,
|
||||
brainRoot: string,
|
||||
): void {
|
||||
assertSafeDestinationAncestors(brainRoot, visiblePath);
|
||||
const held = lstatSync(heldPath);
|
||||
const visible = lstatSync(visiblePath);
|
||||
if (
|
||||
!held.isFile() ||
|
||||
held.isSymbolicLink() ||
|
||||
!visible.isFile() ||
|
||||
visible.isSymbolicLink() ||
|
||||
held.dev !== visible.dev ||
|
||||
held.ino !== visible.ino
|
||||
) {
|
||||
throw new Error('migration-destination-visibility-changed');
|
||||
}
|
||||
}
|
||||
|
||||
function sourceSnapshotMatches(
|
||||
snapshot: StableSourceSnapshot,
|
||||
identity: MigrationCandidate['sourceIdentity'],
|
||||
@@ -786,35 +846,50 @@ function copyVerified(
|
||||
source: StableSourceSnapshot,
|
||||
destination: string,
|
||||
brainRoot: string,
|
||||
hooks: MigrationHooks,
|
||||
): boolean {
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
if (existsSync(destination)) {
|
||||
const status = lstatSync(destination);
|
||||
if (!status.isFile() || status.isSymbolicLink()) {
|
||||
throw new Error('append-only-destination-unsafe');
|
||||
}
|
||||
const destinationDigest = createHash('sha256').update(readFileSync(destination)).digest('hex');
|
||||
if (source.digest !== destinationDigest) throw new Error('append-only-collision');
|
||||
return false;
|
||||
}
|
||||
const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`;
|
||||
const opened = openDirectorySecure(dirname(destination));
|
||||
const name = basename(destination);
|
||||
const heldDestination = procDescriptorPath(opened.fd, name);
|
||||
const temporaryName = `.${name}.tmp-${process.pid}-${randomUUID()}`;
|
||||
const heldTemporary = procDescriptorPath(opened.fd, temporaryName);
|
||||
try {
|
||||
writeFileSync(temporary, source.content, { mode: 0o600, flag: 'wx' });
|
||||
const temporaryStatus = lstatSync(temporary);
|
||||
hooks.beforeDestinationWrite?.(destination);
|
||||
if (existsSync(heldDestination)) {
|
||||
const status = lstatSync(heldDestination);
|
||||
if (!status.isFile() || status.isSymbolicLink()) {
|
||||
throw new Error('append-only-destination-unsafe');
|
||||
}
|
||||
const destinationDigest = createHash('sha256')
|
||||
.update(readFileSync(heldDestination))
|
||||
.digest('hex');
|
||||
if (source.digest !== destinationDigest) throw new Error('append-only-collision');
|
||||
verifyHeldDestinationVisible(heldDestination, destination, brainRoot);
|
||||
return false;
|
||||
}
|
||||
writeFileSync(heldTemporary, source.content, { mode: 0o600, flag: 'wx' });
|
||||
const temporaryStatus = lstatSync(heldTemporary);
|
||||
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
|
||||
throw new Error('migration-copy-target-unsafe');
|
||||
}
|
||||
syncFile(temporary);
|
||||
const copiedDigest = createHash('sha256').update(readFileSync(temporary)).digest('hex');
|
||||
syncFile(heldTemporary);
|
||||
const copiedDigest = createHash('sha256').update(readFileSync(heldTemporary)).digest('hex');
|
||||
if (source.digest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
linkSync(temporary, destination);
|
||||
syncFile(destination);
|
||||
linkSync(heldTemporary, heldDestination);
|
||||
syncFile(heldDestination);
|
||||
try {
|
||||
verifyHeldDestinationVisible(heldDestination, destination, brainRoot);
|
||||
} catch (error: unknown) {
|
||||
unlinkSync(heldDestination);
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
rmSync(temporary, { force: true });
|
||||
rmSync(heldTemporary, { force: true });
|
||||
closeDirectorySecure(opened.chain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +897,7 @@ export function migrateBrainState(
|
||||
plan: MigrationPlan,
|
||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||
brainRoot: string,
|
||||
hooks: MigrationHooks = {},
|
||||
): MigrationResult {
|
||||
if (plan.status !== 'ready' || plan.candidates.length === 0) {
|
||||
return {
|
||||
@@ -848,10 +924,10 @@ export function migrateBrainState(
|
||||
if (!sourceSnapshotMatches(source, candidate.sourceIdentity)) {
|
||||
throw new Error('migration-source-changed-before-copy');
|
||||
}
|
||||
if (copyVerified(source, candidate.destination, brainRoot)) {
|
||||
if (copyVerified(source, candidate.destination, brainRoot, hooks)) {
|
||||
created.push(candidate.destination);
|
||||
}
|
||||
if (copyVerified(source, candidate.archive, brainRoot)) {
|
||||
if (copyVerified(source, candidate.archive, brainRoot, hooks)) {
|
||||
created.push(candidate.archive);
|
||||
}
|
||||
published.push(candidate.destination, candidate.archive);
|
||||
|
||||
Reference in New Issue
Block a user