fix(mosaic): harden brain migration snapshots
This commit is contained in:
@@ -331,7 +331,23 @@ describe('R6 — brain layout refuses secret material', (): void => {
|
||||
).toBe(true);
|
||||
}
|
||||
const rules = readFileSync(join(root, '.gitignore'), 'utf8').trim().split('\n');
|
||||
expect(rules).toEqual(['*.token', '*.key', '*.pem', '.env', 'credentials.json']);
|
||||
expect(rules).toEqual(
|
||||
expect.arrayContaining([
|
||||
'*.token',
|
||||
'*.key',
|
||||
'*.pem',
|
||||
'.env',
|
||||
'credentials.json',
|
||||
'*.TOKEN',
|
||||
'*.KEY',
|
||||
'*.PEM',
|
||||
'.env.*',
|
||||
'credentials.*',
|
||||
'secrets.*',
|
||||
'id_rsa',
|
||||
'id_ed25519',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a symlinked layout directory without writing a tracked placeholder outside the brain', async (): Promise<void> => {
|
||||
@@ -626,7 +642,20 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
const laneRoot = join(sourceRoot, 'lanes', 'lane-a');
|
||||
const brainRoot = join(root, 'brain');
|
||||
mkdirSync(laneRoot, { recursive: true });
|
||||
for (const name of ['access.token', 'private.key', 'client.pem', '.env', 'credentials.json']) {
|
||||
const secretNames = [
|
||||
'access.token',
|
||||
'private.key',
|
||||
'client.pem',
|
||||
'CLIENT.PEM',
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
'credentials.json',
|
||||
'credentials.yaml',
|
||||
'id_rsa',
|
||||
'secrets.txt',
|
||||
];
|
||||
for (const name of secretNames) {
|
||||
writeFileSync(join(laneRoot, name), 'DO-NOT-MIGRATE\n');
|
||||
}
|
||||
|
||||
@@ -637,7 +666,7 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
|
||||
expect(plan.status).toBe('ready');
|
||||
expect(plan.candidates).toHaveLength(0);
|
||||
expect(plan.reported).toHaveLength(5);
|
||||
expect(plan.reported).toHaveLength(secretNames.length);
|
||||
expect(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true);
|
||||
expect(existsSync(brainRoot)).toBe(false);
|
||||
});
|
||||
@@ -686,6 +715,45 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
expect(publishedPaths).toContain(candidate.archive);
|
||||
});
|
||||
|
||||
it.each(['modified', 'replaced'] as const)(
|
||||
'retains and reports a source that is %s while its published snapshot is in flight',
|
||||
async (change): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 source identity before cleanup');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
const brainRoot = join(root, 'brain');
|
||||
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'published snapshot\n');
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
|
||||
const result = sut.migrateBrainState(
|
||||
plan,
|
||||
(): MigrationPublishEvidence => {
|
||||
if (change === 'replaced') rmSync(source);
|
||||
writeFileSync(source, 'new concurrent state\n');
|
||||
return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true };
|
||||
},
|
||||
brainRoot,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('reported');
|
||||
expect(readFileSync(source, 'utf8')).toBe('new concurrent state\n');
|
||||
expect(readFileSync(plan.candidates[0]!.destination, 'utf8')).toBe('published snapshot\n');
|
||||
expect(result.reported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: source,
|
||||
reason: expect.stringMatching(/changed|identity/i),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
closeSync,
|
||||
constants as fsConstants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
@@ -22,7 +22,28 @@ import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js
|
||||
|
||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const COMMIT = /^[0-9a-f]{40}$/;
|
||||
const GITIGNORE_RULES = ['*.token', '*.key', '*.pem', '.env', 'credentials.json'] as const;
|
||||
const GITIGNORE_RULES = [
|
||||
'*.token',
|
||||
'*.key',
|
||||
'*.pem',
|
||||
'.env',
|
||||
'credentials.json',
|
||||
'*.TOKEN',
|
||||
'*.KEY',
|
||||
'*.PEM',
|
||||
'*.p12',
|
||||
'*.pfx',
|
||||
'*.jks',
|
||||
'*.keystore',
|
||||
'.env.*',
|
||||
'credentials.*',
|
||||
'secret.*',
|
||||
'secrets.*',
|
||||
'id_rsa',
|
||||
'id_dsa',
|
||||
'id_ecdsa',
|
||||
'id_ed25519',
|
||||
] as const;
|
||||
const BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const;
|
||||
const TERMINAL_EXITS = {
|
||||
ok: 0,
|
||||
@@ -108,6 +129,11 @@ export interface MigrationCandidate {
|
||||
readonly destination: string;
|
||||
readonly archive: string;
|
||||
readonly kind: 'lane' | 'seat';
|
||||
readonly sourceIdentity: {
|
||||
readonly dev: number | bigint;
|
||||
readonly ino: number | bigint;
|
||||
readonly digest: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MigrationReport {
|
||||
@@ -536,11 +562,42 @@ function ownerIsValid(
|
||||
return true;
|
||||
}
|
||||
|
||||
function migrationDigest(sourceRoot: string, path: string): string {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('migration-source-not-regular');
|
||||
const key = relative(sourceRoot, path).split(sep).join('/');
|
||||
return createHash('sha256').update(key).update('\0').update(readFileSync(path)).digest('hex');
|
||||
interface StableSourceSnapshot {
|
||||
readonly content: Buffer;
|
||||
readonly dev: number | bigint;
|
||||
readonly ino: number | bigint;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
function stableSourceSnapshot(path: string): StableSourceSnapshot {
|
||||
const descriptor = openSync(
|
||||
path,
|
||||
fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const before = fstatSync(descriptor);
|
||||
if (!before.isFile()) throw new Error('migration-source-not-regular');
|
||||
const content = readFileSync(descriptor);
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
!after.isFile() ||
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs ||
|
||||
content.byteLength !== after.size
|
||||
) {
|
||||
throw new Error('migration-source-changed-during-read');
|
||||
}
|
||||
return {
|
||||
content,
|
||||
dev: after.dev,
|
||||
ino: after.ino,
|
||||
digest: createHash('sha256').update(content).digest('hex'),
|
||||
};
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function migrationCandidate(
|
||||
@@ -551,7 +608,14 @@ function migrationCandidate(
|
||||
seat: string,
|
||||
lane: string,
|
||||
): MigrationCandidate {
|
||||
const digest = migrationDigest(sourceRoot, path).slice(0, 16);
|
||||
const source = stableSourceSnapshot(path);
|
||||
const key = relative(sourceRoot, path).split(sep).join('/');
|
||||
const digest = createHash('sha256')
|
||||
.update(key)
|
||||
.update('\0')
|
||||
.update(source.content)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
const name = `${digest}-${basename(path)}`;
|
||||
const destination =
|
||||
kind === 'lane'
|
||||
@@ -562,17 +626,25 @@ function migrationCandidate(
|
||||
destination,
|
||||
archive: join(brainRoot, 'archives', 'imports', kind, name),
|
||||
kind,
|
||||
sourceIdentity: { dev: source.dev, ino: source.ino, digest: source.digest },
|
||||
};
|
||||
}
|
||||
|
||||
function secretShapedPath(path: string): boolean {
|
||||
const name = basename(path);
|
||||
const name = basename(path).toLowerCase();
|
||||
return (
|
||||
name === '.env' ||
|
||||
name.startsWith('.env.') ||
|
||||
name === 'credentials.json' ||
|
||||
name.endsWith('.token') ||
|
||||
name.endsWith('.key') ||
|
||||
name.endsWith('.pem')
|
||||
name.startsWith('credentials.') ||
|
||||
name === 'secret' ||
|
||||
name.startsWith('secret.') ||
|
||||
name === 'secrets' ||
|
||||
name.startsWith('secrets.') ||
|
||||
['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'].includes(name) ||
|
||||
['.token', '.key', '.pem', '.p12', '.pfx', '.jks', '.keystore'].some((suffix): boolean =>
|
||||
name.endsWith(suffix),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -695,7 +767,22 @@ function assertSafeDestinationAncestors(root: string, destination: string): void
|
||||
}
|
||||
}
|
||||
|
||||
function copyVerified(source: string, destination: string, brainRoot: string): boolean {
|
||||
function sourceSnapshotMatches(
|
||||
snapshot: StableSourceSnapshot,
|
||||
identity: MigrationCandidate['sourceIdentity'],
|
||||
): boolean {
|
||||
return (
|
||||
snapshot.dev === identity.dev &&
|
||||
snapshot.ino === identity.ino &&
|
||||
snapshot.digest === identity.digest
|
||||
);
|
||||
}
|
||||
|
||||
function copyVerified(
|
||||
source: StableSourceSnapshot,
|
||||
destination: string,
|
||||
brainRoot: string,
|
||||
): boolean {
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
@@ -704,22 +791,20 @@ function copyVerified(source: string, destination: string, brainRoot: string): b
|
||||
if (!status.isFile() || status.isSymbolicLink()) {
|
||||
throw new Error('append-only-destination-unsafe');
|
||||
}
|
||||
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
|
||||
const destinationDigest = createHash('sha256').update(readFileSync(destination)).digest('hex');
|
||||
if (sourceDigest !== destinationDigest) throw new Error('append-only-collision');
|
||||
if (source.digest !== destinationDigest) throw new Error('append-only-collision');
|
||||
return false;
|
||||
}
|
||||
const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`;
|
||||
try {
|
||||
copyFileSync(source, temporary, fsConstants.COPYFILE_EXCL);
|
||||
writeFileSync(temporary, source.content, { mode: 0o600, flag: 'wx' });
|
||||
const temporaryStatus = lstatSync(temporary);
|
||||
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
|
||||
throw new Error('migration-copy-target-unsafe');
|
||||
}
|
||||
syncFile(temporary);
|
||||
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
|
||||
const copiedDigest = createHash('sha256').update(readFileSync(temporary)).digest('hex');
|
||||
if (sourceDigest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
||||
if (source.digest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
linkSync(temporary, destination);
|
||||
syncFile(destination);
|
||||
@@ -755,10 +840,14 @@ export function migrateBrainState(
|
||||
) {
|
||||
throw new Error('migration-destination-escaped-brain');
|
||||
}
|
||||
if (copyVerified(candidate.source, candidate.destination, brainRoot)) {
|
||||
const source = stableSourceSnapshot(candidate.source);
|
||||
if (!sourceSnapshotMatches(source, candidate.sourceIdentity)) {
|
||||
throw new Error('migration-source-changed-before-copy');
|
||||
}
|
||||
if (copyVerified(source, candidate.destination, brainRoot)) {
|
||||
created.push(candidate.destination);
|
||||
}
|
||||
if (copyVerified(candidate.source, candidate.archive, brainRoot)) {
|
||||
if (copyVerified(source, candidate.archive, brainRoot)) {
|
||||
created.push(candidate.archive);
|
||||
}
|
||||
published.push(candidate.destination, candidate.archive);
|
||||
@@ -797,6 +886,21 @@ export function migrateBrainState(
|
||||
const removalReports: MigrationReport[] = [];
|
||||
for (const candidate of plan.candidates) {
|
||||
try {
|
||||
const current = stableSourceSnapshot(candidate.source);
|
||||
const beforeUnlink = lstatSync(candidate.source);
|
||||
if (
|
||||
!sourceSnapshotMatches(current, candidate.sourceIdentity) ||
|
||||
!beforeUnlink.isFile() ||
|
||||
beforeUnlink.isSymbolicLink() ||
|
||||
beforeUnlink.dev !== current.dev ||
|
||||
beforeUnlink.ino !== current.ino
|
||||
) {
|
||||
removalReports.push({
|
||||
path: candidate.source,
|
||||
reason: 'Source identity or content changed after publication; retained and reported.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
unlinkSync(candidate.source);
|
||||
migrated.push(candidate);
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user