From 6afb3cb5b376b0005b5dcfb3c37fc896b0605f1e Mon Sep 17 00:00:00 2001 From: be-coder-07 Date: Wed, 5 Aug 2026 13:37:08 -0500 Subject: [PATCH] fix(mosaic): hold brain migration destinations by descriptor --- .../mosaic/src/commands/brain-store.spec.ts | 43 +++++++ packages/mosaic/src/commands/brain-store.ts | 118 ++++++++++++++---- 2 files changed, 140 insertions(+), 21 deletions(-) diff --git a/packages/mosaic/src/commands/brain-store.spec.ts b/packages/mosaic/src/commands/brain-store.spec.ts index 9d39f7ad..345af6fd 100644 --- a/packages/mosaic/src/commands/brain-store.spec.ts +++ b/packages/mosaic/src/commands/brain-store.spec.ts @@ -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 => { + 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 => { const sut = await loadSut('MB-REQ-07 migration destination no-follow'); const root = tempRoot(); diff --git a/packages/mosaic/src/commands/brain-store.ts b/packages/mosaic/src/commands/brain-store.ts index 6a45f5a1..174622d8 100644 --- a/packages/mosaic/src/commands/brain-store.ts +++ b/packages/mosaic/src/commands/brain-store.ts @@ -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);