fix(mosaic): bind brain commits to approved blobs
This commit is contained in:
@@ -89,7 +89,7 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati
|
|||||||
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed.
|
- [x] R6 migration reports filename- or content-secret-shaped files without copying them; arbitrary legacy content requires an approved scanner bound to the exact source snapshot, and production currently retains/reports when no approved scanner is configured. Symlinked `.gitignore`, layout directories, and nested migration destinations fail closed.
|
||||||
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
|
- [x] Provider owner lookup uses manual redirect handling, a five-second abort signal, strict JSON content type/shape, and an incrementally enforced 256 KiB response ceiling.
|
||||||
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
|
- [x] Automatic source deletion is parked per the shared-Git-identity governance ruling; remotely reachable snapshots still leave and report every source.
|
||||||
- [x] Multi-host push-on-write uses an isolated temporary Git index, verifies the exact changed-path allowlist plus both author/committer trailers before push, retries non-fast-forward races via identity-scoped fetch/rebase rather than LWW, and retains both sources and local copies when publication reachability is unknown. A real-repository abuse test proves unrelated pre-staged secret-shaped content remains staged but never enters the published commit.
|
- [x] Multi-host push-on-write uses an isolated temporary Git index populated from approved in-memory blobs rather than pathname re-reads, verifies each committed blob ID, the exact changed-path allowlist, and both author/committer trailers before push, retries non-fast-forward races via identity-scoped fetch/rebase rather than LWW, and retains both sources and local copies when publication reachability is unknown. A real-repository abuse test proves destination-path substitution cannot change committed bytes and unrelated pre-staged secret-shaped content remains staged but never enters the published commit.
|
||||||
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
|
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
|
||||||
- [ ] Implementation green on merged dependency base.
|
- [ ] Implementation green on merged dependency base.
|
||||||
- [ ] Independent code review.
|
- [ ] Independent code review.
|
||||||
|
|||||||
@@ -326,11 +326,21 @@ describe('P7 brain provisioning orchestration', (): void => {
|
|||||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||||
return { status: 0, stdout: '', stderr: '' };
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
}
|
}
|
||||||
if (command.includes(' add -- ')) {
|
if (command.includes('read-tree')) {
|
||||||
const separator = request.args.indexOf('--');
|
approvedPaths = [];
|
||||||
approvedPaths = separator === -1 ? [] : [...request.args.slice(separator + 1)];
|
|
||||||
return { status: 0, stdout: '', stderr: '' };
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
}
|
}
|
||||||
|
if (command.includes('hash-object')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('update-index')) {
|
||||||
|
const path = request.args.at(-1);
|
||||||
|
if (path !== undefined) approvedPaths.push(path);
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
if (command.includes('diff --cached --quiet')) {
|
if (command.includes('diff --cached --quiet')) {
|
||||||
return { status: 1, stdout: '', stderr: '' };
|
return { status: 1, stdout: '', stderr: '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { lstatSync } from 'node:fs';
|
|
||||||
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
|
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
|
||||||
import {
|
import {
|
||||||
createBrainSkeleton,
|
createBrainSkeleton,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
migrateBrainState,
|
migrateBrainState,
|
||||||
type MigrationResult,
|
type MigrationResult,
|
||||||
type MigrationOwnerResolution,
|
type MigrationOwnerResolution,
|
||||||
|
type MigrationPublishEntry,
|
||||||
} from './brain-store.js';
|
} from './brain-store.js';
|
||||||
import {
|
import {
|
||||||
collectBrainDoctorReport,
|
collectBrainDoctorReport,
|
||||||
@@ -181,20 +181,20 @@ export async function provisionBrain(
|
|||||||
return blocked('brain-postcondition-failed', report.findings);
|
return blocked('brain-postcondition-failed', report.findings);
|
||||||
}
|
}
|
||||||
|
|
||||||
let skeletonPaths: readonly string[];
|
let skeletonEntries: readonly MigrationPublishEntry[];
|
||||||
try {
|
try {
|
||||||
const skeleton = createBrainSkeleton(input.root);
|
const skeleton = createBrainSkeleton(input.root);
|
||||||
skeletonPaths = skeleton.created.filter((path: string): boolean => lstatSync(path).isFile());
|
skeletonEntries = skeleton.publicationEntries;
|
||||||
} catch {
|
} catch {
|
||||||
return failed('brain-skeleton-failed', report.findings);
|
return failed('brain-skeleton-failed', report.findings);
|
||||||
}
|
}
|
||||||
if (skeletonPaths.length > 0) {
|
if (skeletonEntries.length > 0) {
|
||||||
try {
|
try {
|
||||||
const evidence = publishBrainPaths(
|
const evidence = publishBrainPaths(
|
||||||
{
|
{
|
||||||
root: input.root,
|
root: input.root,
|
||||||
identity: input.identity,
|
identity: input.identity,
|
||||||
paths: skeletonPaths,
|
entries: skeletonEntries,
|
||||||
message: 'chore: seed durable brain layout',
|
message: 'chore: seed durable brain layout',
|
||||||
},
|
},
|
||||||
dependencies.run,
|
dependencies.run,
|
||||||
@@ -218,12 +218,12 @@ export async function provisionBrain(
|
|||||||
);
|
);
|
||||||
const migration = migrateBrainState(
|
const migration = migrateBrainState(
|
||||||
plan,
|
plan,
|
||||||
(brainRoot: string, paths: readonly string[]) =>
|
(brainRoot: string, entries: readonly MigrationPublishEntry[]) =>
|
||||||
publishBrainPaths(
|
publishBrainPaths(
|
||||||
{
|
{
|
||||||
root: brainRoot,
|
root: brainRoot,
|
||||||
identity: input.identity,
|
identity: input.identity,
|
||||||
paths,
|
entries,
|
||||||
message: `migrate: archive ${input.lane} working memory`,
|
message: `migrate: archive ${input.lane} working memory`,
|
||||||
},
|
},
|
||||||
dependencies.run,
|
dependencies.run,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface CommandRequest {
|
|||||||
readonly args: readonly string[];
|
readonly args: readonly string[];
|
||||||
readonly cwd?: string;
|
readonly cwd?: string;
|
||||||
readonly env: Readonly<Record<string, string>>;
|
readonly env: Readonly<Record<string, string>>;
|
||||||
|
readonly stdin?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommandResult {
|
interface CommandResult {
|
||||||
@@ -86,7 +87,7 @@ interface BrainRuntimeModule {
|
|||||||
input: {
|
input: {
|
||||||
readonly root: string;
|
readonly root: string;
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
readonly paths: readonly string[];
|
readonly entries: readonly { readonly path: string; readonly content: Uint8Array }[];
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
},
|
},
|
||||||
run: CommandRunner,
|
run: CommandRunner,
|
||||||
@@ -546,7 +547,7 @@ describe('R5 production refusal control', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('push-on-write publication', (): void => {
|
describe('push-on-write publication', (): void => {
|
||||||
it('never commits or pushes an unrelated pre-staged secret-shaped file', async (): Promise<void> => {
|
it('publishes approved in-memory blobs despite path substitution and excludes unrelated staged secrets', async (): Promise<void> => {
|
||||||
const runtime = await loadRuntime('MB-REQ-06 isolated publication index');
|
const runtime = await loadRuntime('MB-REQ-06 isolated publication index');
|
||||||
const root = tempRoot();
|
const root = tempRoot();
|
||||||
const remote = join(root, 'remote.git');
|
const remote = join(root, 'remote.git');
|
||||||
@@ -571,13 +572,13 @@ describe('push-on-write publication', (): void => {
|
|||||||
writeFileSync(join(checkout, '.env'), 'TEST_ONLY_SECRET=fixture-value\n');
|
writeFileSync(join(checkout, '.env'), 'TEST_ONLY_SECRET=fixture-value\n');
|
||||||
execFileSync('git', ['-C', checkout, 'add', '-f', '.env']);
|
execFileSync('git', ['-C', checkout, 'add', '-f', '.env']);
|
||||||
const approved = join(checkout, 'finding.md');
|
const approved = join(checkout, 'finding.md');
|
||||||
writeFileSync(approved, 'approved finding\n');
|
writeFileSync(approved, 'TEST_ONLY_SECRET=path-substitution\n');
|
||||||
|
|
||||||
const evidence = runtime.publishBrainPaths(
|
const evidence = runtime.publishBrainPaths(
|
||||||
{
|
{
|
||||||
root: checkout,
|
root: checkout,
|
||||||
identity: 'seat-a',
|
identity: 'seat-a',
|
||||||
paths: [approved],
|
entries: [{ path: approved, content: new TextEncoder().encode('approved finding\n') }],
|
||||||
message: 'append approved finding',
|
message: 'append approved finding',
|
||||||
},
|
},
|
||||||
runtime.systemCommandRunner,
|
runtime.systemCommandRunner,
|
||||||
@@ -599,7 +600,14 @@ describe('push-on-write publication', (): void => {
|
|||||||
'--cached',
|
'--cached',
|
||||||
'--name-only',
|
'--name-only',
|
||||||
]).toString();
|
]).toString();
|
||||||
|
const committedContent = execFileSync('git', [
|
||||||
|
'-C',
|
||||||
|
checkout,
|
||||||
|
'show',
|
||||||
|
`${evidence.commit}:finding.md`,
|
||||||
|
]).toString();
|
||||||
expect(committed.trim().split('\n')).toEqual(['finding.md']);
|
expect(committed.trim().split('\n')).toEqual(['finding.md']);
|
||||||
|
expect(committedContent).toBe('approved finding\n');
|
||||||
expect(staged.trim().split('\n')).toContain('.env');
|
expect(staged.trim().split('\n')).toContain('.env');
|
||||||
expect(evidence.reachable).toBe(true);
|
expect(evidence.reachable).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -613,6 +621,12 @@ describe('push-on-write publication', (): void => {
|
|||||||
const runner: CommandRunner = (request): CommandResult => {
|
const runner: CommandRunner = (request): CommandResult => {
|
||||||
requests.push(request);
|
requests.push(request);
|
||||||
const command = request.args.join(' ');
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('hash-object')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
if (command.includes('rev-parse HEAD'))
|
if (command.includes('rev-parse HEAD'))
|
||||||
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
||||||
if (command.includes('rev-parse origin/main')) {
|
if (command.includes('rev-parse origin/main')) {
|
||||||
@@ -638,7 +652,10 @@ describe('push-on-write publication', (): void => {
|
|||||||
{
|
{
|
||||||
root,
|
root,
|
||||||
identity: 'seat-a',
|
identity: 'seat-a',
|
||||||
paths: [join(root, '.gitignore'), join(root, 'lanes', 'lane-a', '.gitkeep')],
|
entries: [
|
||||||
|
{ path: join(root, '.gitignore'), content: new TextEncoder().encode('*.token\n') },
|
||||||
|
{ path: join(root, 'lanes', 'lane-a', '.gitkeep'), content: new Uint8Array() },
|
||||||
|
],
|
||||||
message: 'migrate lane-a state',
|
message: 'migrate lane-a state',
|
||||||
},
|
},
|
||||||
runner,
|
runner,
|
||||||
@@ -648,7 +665,11 @@ describe('push-on-write publication', (): void => {
|
|||||||
const rendered = requests.map((request) => `${request.program} ${request.args.join(' ')}`);
|
const rendered = requests.map((request) => `${request.program} ${request.args.join(' ')}`);
|
||||||
expect(rendered).toEqual(
|
expect(rendered).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringMatching(/git -C .* add -- \.gitignore lanes\/lane-a\/\.gitkeep/),
|
expect.stringMatching(/git -C .* hash-object -w --stdin/),
|
||||||
|
expect.stringMatching(/git -C .* update-index --add --cacheinfo 100644 .* \.gitignore/),
|
||||||
|
expect.stringMatching(
|
||||||
|
/git -C .* update-index --add --cacheinfo 100644 .* lanes\/lane-a\/\.gitkeep/,
|
||||||
|
),
|
||||||
expect.stringMatching(
|
expect.stringMatching(
|
||||||
/git -C .* -c user\.name=seat-a -c user\.email=seat-a@fleet\.mosaicstack\.dev commit/,
|
/git -C .* -c user\.name=seat-a -c user\.email=seat-a@fleet\.mosaicstack\.dev commit/,
|
||||||
),
|
),
|
||||||
@@ -679,12 +700,23 @@ describe('push-on-write publication', (): void => {
|
|||||||
{
|
{
|
||||||
root,
|
root,
|
||||||
identity: 'seat-a',
|
identity: 'seat-a',
|
||||||
paths: [join(root, 'lanes', 'lane-a', 'findings', 'host-a.md')],
|
entries: [
|
||||||
|
{
|
||||||
|
path: join(root, 'lanes', 'lane-a', 'findings', 'host-a.md'),
|
||||||
|
content: new TextEncoder().encode('finding\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
message: 'append host-a finding',
|
message: 'append host-a finding',
|
||||||
},
|
},
|
||||||
(request): CommandResult => {
|
(request): CommandResult => {
|
||||||
requests.push(request);
|
requests.push(request);
|
||||||
const command = request.args.join(' ');
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('hash-object')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
if (command.includes('push origin HEAD:main')) {
|
if (command.includes('push origin HEAD:main')) {
|
||||||
pushes += 1;
|
pushes += 1;
|
||||||
return pushes === 1
|
return pushes === 1
|
||||||
@@ -749,12 +781,20 @@ describe('push-on-write publication', (): void => {
|
|||||||
{
|
{
|
||||||
root,
|
root,
|
||||||
identity: 'seat-a',
|
identity: 'seat-a',
|
||||||
paths: [join(root, 'lanes', 'lane-a', 'already-present.md')],
|
entries: [
|
||||||
|
{
|
||||||
|
path: join(root, 'lanes', 'lane-a', 'already-present.md'),
|
||||||
|
content: new TextEncoder().encode('existing\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
message: 'append existing finding',
|
message: 'append existing finding',
|
||||||
},
|
},
|
||||||
(request): CommandResult => {
|
(request): CommandResult => {
|
||||||
requests.push(request);
|
requests.push(request);
|
||||||
const command = request.args.join(' ');
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('hash-object')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
if (command.includes(' commit ')) {
|
if (command.includes(' commit ')) {
|
||||||
return { status: 1, stdout: '', stderr: 'nothing to commit' };
|
return { status: 1, stdout: '', stderr: 'nothing to commit' };
|
||||||
}
|
}
|
||||||
@@ -782,11 +822,19 @@ describe('push-on-write publication', (): void => {
|
|||||||
{
|
{
|
||||||
root,
|
root,
|
||||||
identity: 'seat-a',
|
identity: 'seat-a',
|
||||||
paths: [join(root, '.gitignore')],
|
entries: [
|
||||||
|
{ path: join(root, '.gitignore'), content: new TextEncoder().encode('*.token\n') },
|
||||||
|
],
|
||||||
message: 'seed brain',
|
message: 'seed brain',
|
||||||
},
|
},
|
||||||
(request): CommandResult => {
|
(request): CommandResult => {
|
||||||
const command = request.args.join(' ');
|
const command = request.args.join(' ');
|
||||||
|
if (command.includes('hash-object')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.includes('rev-parse') && request.args.at(-1)?.includes(':')) {
|
||||||
|
return { status: 0, stdout: `${'f'.repeat(40)}\n`, stderr: '' };
|
||||||
|
}
|
||||||
if (command.includes('rev-parse HEAD')) {
|
if (command.includes('rev-parse HEAD')) {
|
||||||
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
} from './brain-store.js';
|
} from './brain-store.js';
|
||||||
|
|
||||||
const COMMIT = /^[0-9a-f]{40}$/;
|
const COMMIT = /^[0-9a-f]{40}$/;
|
||||||
|
const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||||
|
const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
export interface CommandRequest {
|
export interface CommandRequest {
|
||||||
readonly program: 'git' | 'mosaic';
|
readonly program: 'git' | 'mosaic';
|
||||||
readonly args: readonly string[];
|
readonly args: readonly string[];
|
||||||
readonly cwd?: string;
|
readonly cwd?: string;
|
||||||
readonly env: Readonly<Record<string, string>>;
|
readonly env: Readonly<Record<string, string>>;
|
||||||
|
readonly stdin?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandResult {
|
export interface CommandResult {
|
||||||
@@ -35,6 +38,7 @@ export const systemCommandRunner: CommandRunner = (request: CommandRequest): Com
|
|||||||
env: { ...process.env, ...request.env },
|
env: { ...process.env, ...request.env },
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
|
input: request.stdin,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
status: result.status ?? 127,
|
status: result.status ?? 127,
|
||||||
@@ -90,8 +94,9 @@ function runGitWithEnv(
|
|||||||
identity: string,
|
identity: string,
|
||||||
args: readonly string[],
|
args: readonly string[],
|
||||||
env: Readonly<Record<string, string>>,
|
env: Readonly<Record<string, string>>,
|
||||||
|
stdin?: Uint8Array,
|
||||||
): CommandResult {
|
): CommandResult {
|
||||||
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env } });
|
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectBrainDoctorReport(
|
export function collectBrainDoctorReport(
|
||||||
@@ -317,14 +322,27 @@ export function publishBrainPaths(
|
|||||||
input: {
|
input: {
|
||||||
readonly root: string;
|
readonly root: string;
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
readonly paths: readonly string[];
|
readonly entries: readonly {
|
||||||
|
readonly path: string;
|
||||||
|
readonly content: Uint8Array;
|
||||||
|
}[];
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
},
|
},
|
||||||
run: CommandRunner,
|
run: CommandRunner,
|
||||||
): PublishEvidence {
|
): PublishEvidence {
|
||||||
if (input.paths.length === 0) throw new Error('brain-publish-paths-empty');
|
if (input.entries.length === 0) throw new Error('brain-publish-paths-empty');
|
||||||
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
||||||
const paths = input.paths.map((path: string): string => containedRelative(input.root, path));
|
const entries = input.entries.map((entry) => {
|
||||||
|
if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) {
|
||||||
|
throw new Error('brain-publish-entry-too-large');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: containedRelative(input.root, entry.path),
|
||||||
|
content: Uint8Array.from(entry.content),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const paths = entries.map((entry): string => entry.path);
|
||||||
|
if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate');
|
||||||
|
|
||||||
const readHead = (): string => {
|
const readHead = (): string => {
|
||||||
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
|
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
|
||||||
@@ -354,6 +372,19 @@ export function publishBrainPaths(
|
|||||||
throw new Error('brain-git-commit-identity-mismatch');
|
throw new Error('brain-git-commit-identity-mismatch');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const expectedObjects = new Map<string, string>();
|
||||||
|
const verifyCommitObjects = (commit: string): void => {
|
||||||
|
for (const [path, expected] of expectedObjects) {
|
||||||
|
const result = runGit(run, input.identity, [
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'rev-parse',
|
||||||
|
`${commit}:${path}`,
|
||||||
|
]);
|
||||||
|
requireSuccess(result, 'brain-git-read-commit-object');
|
||||||
|
if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch');
|
||||||
|
}
|
||||||
|
};
|
||||||
const verifyCommitPaths = (commit: string): void => {
|
const verifyCommitPaths = (commit: string): void => {
|
||||||
const changed = runGit(run, input.identity, [
|
const changed = runGit(run, input.identity, [
|
||||||
'-C',
|
'-C',
|
||||||
@@ -384,10 +415,37 @@ export function publishBrainPaths(
|
|||||||
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
|
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
|
||||||
'brain-git-isolated-index-init',
|
'brain-git-isolated-index-init',
|
||||||
);
|
);
|
||||||
requireSuccess(
|
for (const entry of entries) {
|
||||||
runGitWithEnv(run, input.identity, ['-C', input.root, 'add', '--', ...paths], isolatedEnv),
|
const object = runGitWithEnv(
|
||||||
'brain-git-add',
|
run,
|
||||||
);
|
input.identity,
|
||||||
|
['-C', input.root, 'hash-object', '-w', '--stdin'],
|
||||||
|
isolatedEnv,
|
||||||
|
entry.content,
|
||||||
|
);
|
||||||
|
requireSuccess(object, 'brain-git-write-approved-object');
|
||||||
|
const objectId = object.stdout.trim();
|
||||||
|
if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid');
|
||||||
|
expectedObjects.set(entry.path, objectId);
|
||||||
|
requireSuccess(
|
||||||
|
runGitWithEnv(
|
||||||
|
run,
|
||||||
|
input.identity,
|
||||||
|
[
|
||||||
|
'-C',
|
||||||
|
input.root,
|
||||||
|
'update-index',
|
||||||
|
'--add',
|
||||||
|
'--cacheinfo',
|
||||||
|
'100644',
|
||||||
|
objectId,
|
||||||
|
entry.path,
|
||||||
|
],
|
||||||
|
isolatedEnv,
|
||||||
|
),
|
||||||
|
'brain-git-stage-approved-object',
|
||||||
|
);
|
||||||
|
}
|
||||||
const difference = runGitWithEnv(
|
const difference = runGitWithEnv(
|
||||||
run,
|
run,
|
||||||
input.identity,
|
input.identity,
|
||||||
@@ -416,6 +474,7 @@ export function publishBrainPaths(
|
|||||||
);
|
);
|
||||||
commit = readHead();
|
commit = readHead();
|
||||||
verifyCommitPaths(commit);
|
verifyCommitPaths(commit);
|
||||||
|
verifyCommitObjects(commit);
|
||||||
verifyCommitIdentity(commit);
|
verifyCommitIdentity(commit);
|
||||||
createdCommit = true;
|
createdCommit = true;
|
||||||
} else if (difference.status !== 0) {
|
} else if (difference.status !== 0) {
|
||||||
@@ -453,6 +512,7 @@ export function publishBrainPaths(
|
|||||||
);
|
);
|
||||||
commit = readHead();
|
commit = readHead();
|
||||||
verifyCommitPaths(commit);
|
verifyCommitPaths(commit);
|
||||||
|
verifyCommitObjects(commit);
|
||||||
verifyCommitIdentity(commit);
|
verifyCommitIdentity(commit);
|
||||||
}
|
}
|
||||||
if (!pushed) throw new Error('brain-git-push-failed');
|
if (!pushed) throw new Error('brain-git-push-failed');
|
||||||
|
|||||||
@@ -137,7 +137,10 @@ interface BrainStoreModule {
|
|||||||
): MigrationPlan;
|
): MigrationPlan;
|
||||||
migrateBrainState(
|
migrateBrainState(
|
||||||
plan: MigrationPlan,
|
plan: MigrationPlan,
|
||||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
publish: (
|
||||||
|
brainRoot: string,
|
||||||
|
entries: readonly { readonly path: string; readonly content: Uint8Array }[],
|
||||||
|
) => MigrationPublishEvidence,
|
||||||
brainRoot: string,
|
brainRoot: string,
|
||||||
hooks?: {
|
hooks?: {
|
||||||
readonly beforeDestinationWrite?: (destination: string) => void;
|
readonly beforeDestinationWrite?: (destination: string) => void;
|
||||||
@@ -758,8 +761,8 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
|||||||
|
|
||||||
const result = sut.migrateBrainState(
|
const result = sut.migrateBrainState(
|
||||||
plan,
|
plan,
|
||||||
(_publishedRoot, paths): MigrationPublishEvidence => {
|
(_publishedRoot, entries): MigrationPublishEvidence => {
|
||||||
publishedPaths = paths;
|
publishedPaths = entries.map((entry): string => entry.path);
|
||||||
return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true };
|
return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true };
|
||||||
},
|
},
|
||||||
brainRoot,
|
brainRoot,
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ export interface MigrationPlan {
|
|||||||
readonly owner: MigrationOwnerResolution['principal'];
|
readonly owner: MigrationOwnerResolution['principal'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MigrationPublishEntry {
|
||||||
|
readonly path: string;
|
||||||
|
readonly content: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MigrationPublishEvidence {
|
export interface MigrationPublishEvidence {
|
||||||
readonly commit: string;
|
readonly commit: string;
|
||||||
readonly remoteHead: string;
|
readonly remoteHead: string;
|
||||||
@@ -348,8 +353,12 @@ function syncFile(path: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBrainSkeleton(root: string): { readonly created: readonly string[] } {
|
export function createBrainSkeleton(root: string): {
|
||||||
|
readonly created: readonly string[];
|
||||||
|
readonly publicationEntries: readonly MigrationPublishEntry[];
|
||||||
|
} {
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
|
const publicationEntries: MigrationPublishEntry[] = [];
|
||||||
if (existsSync(root)) {
|
if (existsSync(root)) {
|
||||||
const rootStatus = lstatSync(root);
|
const rootStatus = lstatSync(root);
|
||||||
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) {
|
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) {
|
||||||
@@ -378,6 +387,7 @@ export function createBrainSkeleton(root: string): { readonly created: readonly
|
|||||||
writeFileSync(placeholder, '', { encoding: 'utf8', mode: 0o644, flag: 'wx' });
|
writeFileSync(placeholder, '', { encoding: 'utf8', mode: 0o644, flag: 'wx' });
|
||||||
syncFile(placeholder);
|
syncFile(placeholder);
|
||||||
created.push(placeholder);
|
created.push(placeholder);
|
||||||
|
publicationEntries.push({ path: placeholder, content: new Uint8Array() });
|
||||||
} else if (!lstatSync(placeholder).isFile() || lstatSync(placeholder).isSymbolicLink()) {
|
} else if (!lstatSync(placeholder).isFile() || lstatSync(placeholder).isSymbolicLink()) {
|
||||||
throw new Error('brain-layout-placeholder-unsafe');
|
throw new Error('brain-layout-placeholder-unsafe');
|
||||||
}
|
}
|
||||||
@@ -406,8 +416,9 @@ export function createBrainSkeleton(root: string): { readonly created: readonly
|
|||||||
syncFile(temporary);
|
syncFile(temporary);
|
||||||
renameSync(temporary, ignorePath);
|
renameSync(temporary, ignorePath);
|
||||||
created.push(ignorePath);
|
created.push(ignorePath);
|
||||||
|
publicationEntries.push({ path: ignorePath, content: new TextEncoder().encode(content) });
|
||||||
}
|
}
|
||||||
return { created };
|
return { created, publicationEntries };
|
||||||
}
|
}
|
||||||
|
|
||||||
function indeterminate(reasonCode: string): CredentialAssessment {
|
function indeterminate(reasonCode: string): CredentialAssessment {
|
||||||
@@ -945,7 +956,10 @@ function copyVerified(
|
|||||||
|
|
||||||
export function migrateBrainState(
|
export function migrateBrainState(
|
||||||
plan: MigrationPlan,
|
plan: MigrationPlan,
|
||||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
publish: (
|
||||||
|
brainRoot: string,
|
||||||
|
entries: readonly MigrationPublishEntry[],
|
||||||
|
) => MigrationPublishEvidence,
|
||||||
brainRoot: string,
|
brainRoot: string,
|
||||||
hooks: MigrationHooks = {},
|
hooks: MigrationHooks = {},
|
||||||
): MigrationResult {
|
): MigrationResult {
|
||||||
@@ -959,7 +973,7 @@ export function migrateBrainState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
const published: string[] = [];
|
const published: MigrationPublishEntry[] = [];
|
||||||
let publicationAttempted = false;
|
let publicationAttempted = false;
|
||||||
let evidence: MigrationPublishEvidence;
|
let evidence: MigrationPublishEvidence;
|
||||||
try {
|
try {
|
||||||
@@ -980,7 +994,10 @@ export function migrateBrainState(
|
|||||||
if (copyVerified(source, candidate.archive, brainRoot, hooks)) {
|
if (copyVerified(source, candidate.archive, brainRoot, hooks)) {
|
||||||
created.push(candidate.archive);
|
created.push(candidate.archive);
|
||||||
}
|
}
|
||||||
published.push(candidate.destination, candidate.archive);
|
published.push(
|
||||||
|
{ path: candidate.destination, content: Uint8Array.from(source.content) },
|
||||||
|
{ path: candidate.archive, content: Uint8Array.from(source.content) },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
publicationAttempted = true;
|
publicationAttempted = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user