fix(mosaic): hold brain migration destinations by descriptor

This commit is contained in:
2026-08-05 17:19:50 -05:00
parent 351cb67cec
commit 6afb3cb5b3
2 changed files with 140 additions and 21 deletions
@@ -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();
+97 -21
View File
@@ -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);