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);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
const rules = readFileSync(join(root, '.gitignore'), 'utf8').trim().split('\n');
|
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> => {
|
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 laneRoot = join(sourceRoot, 'lanes', 'lane-a');
|
||||||
const brainRoot = join(root, 'brain');
|
const brainRoot = join(root, 'brain');
|
||||||
mkdirSync(laneRoot, { recursive: true });
|
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');
|
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.status).toBe('ready');
|
||||||
expect(plan.candidates).toHaveLength(0);
|
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(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true);
|
||||||
expect(existsSync(brainRoot)).toBe(false);
|
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);
|
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> => {
|
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();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
closeSync,
|
closeSync,
|
||||||
constants as fsConstants,
|
constants as fsConstants,
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
existsSync,
|
||||||
fsyncSync,
|
fsyncSync,
|
||||||
|
fstatSync,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
linkSync,
|
linkSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
@@ -22,7 +22,28 @@ import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js
|
|||||||
|
|
||||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||||
const COMMIT = /^[0-9a-f]{40}$/;
|
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 BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const;
|
||||||
const TERMINAL_EXITS = {
|
const TERMINAL_EXITS = {
|
||||||
ok: 0,
|
ok: 0,
|
||||||
@@ -108,6 +129,11 @@ export interface MigrationCandidate {
|
|||||||
readonly destination: string;
|
readonly destination: string;
|
||||||
readonly archive: string;
|
readonly archive: string;
|
||||||
readonly kind: 'lane' | 'seat';
|
readonly kind: 'lane' | 'seat';
|
||||||
|
readonly sourceIdentity: {
|
||||||
|
readonly dev: number | bigint;
|
||||||
|
readonly ino: number | bigint;
|
||||||
|
readonly digest: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrationReport {
|
export interface MigrationReport {
|
||||||
@@ -536,11 +562,42 @@ function ownerIsValid(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrationDigest(sourceRoot: string, path: string): string {
|
interface StableSourceSnapshot {
|
||||||
const stat = lstatSync(path);
|
readonly content: Buffer;
|
||||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('migration-source-not-regular');
|
readonly dev: number | bigint;
|
||||||
const key = relative(sourceRoot, path).split(sep).join('/');
|
readonly ino: number | bigint;
|
||||||
return createHash('sha256').update(key).update('\0').update(readFileSync(path)).digest('hex');
|
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(
|
function migrationCandidate(
|
||||||
@@ -551,7 +608,14 @@ function migrationCandidate(
|
|||||||
seat: string,
|
seat: string,
|
||||||
lane: string,
|
lane: string,
|
||||||
): MigrationCandidate {
|
): 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 name = `${digest}-${basename(path)}`;
|
||||||
const destination =
|
const destination =
|
||||||
kind === 'lane'
|
kind === 'lane'
|
||||||
@@ -562,17 +626,25 @@ function migrationCandidate(
|
|||||||
destination,
|
destination,
|
||||||
archive: join(brainRoot, 'archives', 'imports', kind, name),
|
archive: join(brainRoot, 'archives', 'imports', kind, name),
|
||||||
kind,
|
kind,
|
||||||
|
sourceIdentity: { dev: source.dev, ino: source.ino, digest: source.digest },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function secretShapedPath(path: string): boolean {
|
function secretShapedPath(path: string): boolean {
|
||||||
const name = basename(path);
|
const name = basename(path).toLowerCase();
|
||||||
return (
|
return (
|
||||||
name === '.env' ||
|
name === '.env' ||
|
||||||
|
name.startsWith('.env.') ||
|
||||||
name === 'credentials.json' ||
|
name === 'credentials.json' ||
|
||||||
name.endsWith('.token') ||
|
name.startsWith('credentials.') ||
|
||||||
name.endsWith('.key') ||
|
name === 'secret' ||
|
||||||
name.endsWith('.pem')
|
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);
|
assertSafeDestinationAncestors(brainRoot, destination);
|
||||||
mkdirSync(dirname(destination), { recursive: true });
|
mkdirSync(dirname(destination), { recursive: true });
|
||||||
assertSafeDestinationAncestors(brainRoot, destination);
|
assertSafeDestinationAncestors(brainRoot, destination);
|
||||||
@@ -704,22 +791,20 @@ function copyVerified(source: string, destination: string, brainRoot: string): b
|
|||||||
if (!status.isFile() || status.isSymbolicLink()) {
|
if (!status.isFile() || status.isSymbolicLink()) {
|
||||||
throw new Error('append-only-destination-unsafe');
|
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');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`;
|
const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`;
|
||||||
try {
|
try {
|
||||||
copyFileSync(source, temporary, fsConstants.COPYFILE_EXCL);
|
writeFileSync(temporary, source.content, { mode: 0o600, flag: 'wx' });
|
||||||
const temporaryStatus = lstatSync(temporary);
|
const temporaryStatus = lstatSync(temporary);
|
||||||
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(temporary);
|
||||||
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
|
|
||||||
const copiedDigest = createHash('sha256').update(readFileSync(temporary)).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);
|
assertSafeDestinationAncestors(brainRoot, destination);
|
||||||
linkSync(temporary, destination);
|
linkSync(temporary, destination);
|
||||||
syncFile(destination);
|
syncFile(destination);
|
||||||
@@ -755,10 +840,14 @@ export function migrateBrainState(
|
|||||||
) {
|
) {
|
||||||
throw new Error('migration-destination-escaped-brain');
|
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);
|
created.push(candidate.destination);
|
||||||
}
|
}
|
||||||
if (copyVerified(candidate.source, candidate.archive, brainRoot)) {
|
if (copyVerified(source, candidate.archive, brainRoot)) {
|
||||||
created.push(candidate.archive);
|
created.push(candidate.archive);
|
||||||
}
|
}
|
||||||
published.push(candidate.destination, candidate.archive);
|
published.push(candidate.destination, candidate.archive);
|
||||||
@@ -797,6 +886,21 @@ export function migrateBrainState(
|
|||||||
const removalReports: MigrationReport[] = [];
|
const removalReports: MigrationReport[] = [];
|
||||||
for (const candidate of plan.candidates) {
|
for (const candidate of plan.candidates) {
|
||||||
try {
|
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);
|
unlinkSync(candidate.source);
|
||||||
migrated.push(candidate);
|
migrated.push(candidate);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user