fix(mosaic): hold brain migration destinations by descriptor
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -137,6 +138,7 @@ interface BrainStoreModule {
|
|||||||
plan: MigrationPlan,
|
plan: MigrationPlan,
|
||||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||||
brainRoot: string,
|
brainRoot: string,
|
||||||
|
hooks?: { readonly beforeDestinationWrite?: (destination: string) => void },
|
||||||
): MigrationResult;
|
): MigrationResult;
|
||||||
evaluateBrainDoctor(
|
evaluateBrainDoctor(
|
||||||
observation: BrainDoctorObservation,
|
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> => {
|
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 sut = await loadSut('MB-REQ-07 migration destination no-follow');
|
||||||
const root = tempRoot();
|
const root = tempRoot();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { platform } from 'node:os';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||||
|
|
||||||
@@ -154,6 +155,10 @@ export interface MigrationPublishEvidence {
|
|||||||
readonly reachable: boolean;
|
readonly reachable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MigrationHooks {
|
||||||
|
readonly beforeDestinationWrite?: (destination: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MigrationResult {
|
export interface MigrationResult {
|
||||||
readonly status: 'migrated' | 'reported' | 'failed';
|
readonly status: 'migrated' | 'reported' | 'failed';
|
||||||
readonly migrated: readonly MigrationCandidate[];
|
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(
|
function sourceSnapshotMatches(
|
||||||
snapshot: StableSourceSnapshot,
|
snapshot: StableSourceSnapshot,
|
||||||
identity: MigrationCandidate['sourceIdentity'],
|
identity: MigrationCandidate['sourceIdentity'],
|
||||||
@@ -786,35 +846,50 @@ function copyVerified(
|
|||||||
source: StableSourceSnapshot,
|
source: StableSourceSnapshot,
|
||||||
destination: string,
|
destination: string,
|
||||||
brainRoot: string,
|
brainRoot: string,
|
||||||
|
hooks: MigrationHooks,
|
||||||
): boolean {
|
): boolean {
|
||||||
assertSafeDestinationAncestors(brainRoot, destination);
|
assertSafeDestinationAncestors(brainRoot, destination);
|
||||||
mkdirSync(dirname(destination), { recursive: true });
|
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
||||||
assertSafeDestinationAncestors(brainRoot, destination);
|
assertSafeDestinationAncestors(brainRoot, destination);
|
||||||
if (existsSync(destination)) {
|
const opened = openDirectorySecure(dirname(destination));
|
||||||
const status = lstatSync(destination);
|
const name = basename(destination);
|
||||||
if (!status.isFile() || status.isSymbolicLink()) {
|
const heldDestination = procDescriptorPath(opened.fd, name);
|
||||||
throw new Error('append-only-destination-unsafe');
|
const temporaryName = `.${name}.tmp-${process.pid}-${randomUUID()}`;
|
||||||
}
|
const heldTemporary = procDescriptorPath(opened.fd, temporaryName);
|
||||||
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()}`;
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(temporary, source.content, { mode: 0o600, flag: 'wx' });
|
hooks.beforeDestinationWrite?.(destination);
|
||||||
const temporaryStatus = lstatSync(temporary);
|
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()) {
|
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
|
||||||
throw new Error('migration-copy-target-unsafe');
|
throw new Error('migration-copy-target-unsafe');
|
||||||
}
|
}
|
||||||
syncFile(temporary);
|
syncFile(heldTemporary);
|
||||||
const copiedDigest = createHash('sha256').update(readFileSync(temporary)).digest('hex');
|
const copiedDigest = createHash('sha256').update(readFileSync(heldTemporary)).digest('hex');
|
||||||
if (source.digest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
if (source.digest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
||||||
assertSafeDestinationAncestors(brainRoot, destination);
|
linkSync(heldTemporary, heldDestination);
|
||||||
linkSync(temporary, destination);
|
syncFile(heldDestination);
|
||||||
syncFile(destination);
|
try {
|
||||||
|
verifyHeldDestinationVisible(heldDestination, destination, brainRoot);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
unlinkSync(heldDestination);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(temporary, { force: true });
|
rmSync(heldTemporary, { force: true });
|
||||||
|
closeDirectorySecure(opened.chain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +897,7 @@ export function migrateBrainState(
|
|||||||
plan: MigrationPlan,
|
plan: MigrationPlan,
|
||||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||||
brainRoot: string,
|
brainRoot: string,
|
||||||
|
hooks: MigrationHooks = {},
|
||||||
): MigrationResult {
|
): MigrationResult {
|
||||||
if (plan.status !== 'ready' || plan.candidates.length === 0) {
|
if (plan.status !== 'ready' || plan.candidates.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -848,10 +924,10 @@ export function migrateBrainState(
|
|||||||
if (!sourceSnapshotMatches(source, candidate.sourceIdentity)) {
|
if (!sourceSnapshotMatches(source, candidate.sourceIdentity)) {
|
||||||
throw new Error('migration-source-changed-before-copy');
|
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);
|
created.push(candidate.destination);
|
||||||
}
|
}
|
||||||
if (copyVerified(source, candidate.archive, brainRoot)) {
|
if (copyVerified(source, candidate.archive, brainRoot, hooks)) {
|
||||||
created.push(candidate.archive);
|
created.push(candidate.archive);
|
||||||
}
|
}
|
||||||
published.push(candidate.destination, candidate.archive);
|
published.push(candidate.destination, candidate.archive);
|
||||||
|
|||||||
Reference in New Issue
Block a user