blob: e85b9017f9960f6a970c92d076ed891935eeacb8 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test, { type TestContext } from 'node:test';
import {
admitGitoxideHelperArtifactInternal,
GitoxideHelperArtifactAuthorityError,
type GitoxideHelperInvocationCapability,
issueGitoxideHelperReleaseArtifactClaimInternal,
} from '../server/gitoxide-helper-artifact-authority-internal.js';
import {
createCandidateWithGitoxideHelperInternal,
GITOXIDE_HELPER_ERROR_REASONS_V1,
GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL,
GitoxideHelperInvocationError,
importSourceHeadWithGitoxideHelperInternal,
inspectRepositoryWithGitoxideHelperInternal,
readTreeFileWithGitoxideHelperInternal,
runGitoxideOperationWithinDeadlineInternal,
} from '../server/gitoxide-helper-invocation-internal.js';
interface AdmittedHelper {
readonly invocationOwnerToken: object;
readonly capability: GitoxideHelperInvocationCapability;
}
let admittedHelperPromise: Promise<AdmittedHelper | undefined> | undefined;
test('uses bounded mutation/import deadlines distinct from repository inspection', () => {
assert.deepEqual(GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, {
inspectRepositoryMs: 5_000,
importSourceHeadMs: 10 * 60_000,
createCandidateMs: 10 * 60_000,
acceptedTreeReadMs: 10 * 60_000,
});
});
test('bounds preflight work with the same absolute operation deadline', async () => {
const startedAt = performance.now();
await assert.rejects(
runGitoxideOperationWithinDeadlineInternal({
deadlineAt: startedAt + 25,
operation: () => new Promise<never>(() => {}),
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_timed_out',
);
assert.ok(performance.now() - startedAt < 1_000);
});
test('rejects repository inspection when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, ['import_source_head']);
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
});
test('rejects source import when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, [
'inspect_repository',
'create_candidate',
'read_tree_file',
]);
const sourceRepositoryPath = await createRepository(t, 'sha1');
await writeFile(join(sourceRepositoryPath, 'source.txt'), 'source\n');
git(sourceRepositoryPath, ['add', 'source.txt']);
git(sourceRepositoryPath, [
'-c',
'user.name=Maka Test',
'-c',
'user.email=maka@example.invalid',
'commit',
'--quiet',
'-m',
'fixture',
]);
const destinationRepositoryPath = join(sourceRepositoryPath, 'must-not-exist.git');
await assert.rejects(
importSourceHeadWithGitoxideHelperInternal({
...helper,
sourceRepositoryPath,
expectedSourceHeadCommitOid: git(sourceRepositoryPath, ['rev-parse', 'HEAD']),
destinationRepositoryPath,
baselineRef: 'refs/maka/accepted',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
await assert.rejects(stat(destinationRepositoryPath), { code: 'ENOENT' });
});
test('rejects candidate creation when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, [
'inspect_repository',
'import_source_head',
'read_tree_file',
]);
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
createCandidateWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedRef: 'refs/maka/accepted',
expectedBaseCommitOid: 'a'.repeat(40),
expectedBaseTreeOid: 'b'.repeat(40),
candidateRef: `refs/maka/candidates/${'c'.repeat(64)}`,
path: 'result.txt',
content: 'result\n',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
});
test('rejects accepted-tree reads when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, [
'inspect_repository',
'import_source_head',
'create_candidate',
]);
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
readTreeFileWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedCommitOid: 'a'.repeat(40),
path: 'result.txt',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
});
test('waits for helper process identity by elapsed time instead of scheduler turns', async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-marker-')));
t.after(() => rm(root, { recursive: true, force: true }));
const markerPath = join(root, 'processes.txt');
const publication = new Promise<void>((resolve, reject) => {
setTimeout(() => {
void writeFile(markerPath, '123 456').then(resolve, reject);
}, 1_000);
});
assert.deepEqual(await waitForProcessMarker(markerPath, 2_000), [123, 456]);
await publication;
});
test('applies the import deadline and terminates the helper process tree', {
skip: process.platform === 'win32',
}, async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-timeout-')));
t.after(() => rm(root, { recursive: true, force: true }));
const markerPath = join(root, 'processes.txt');
const helperPath = join(root, 'hanging-helper');
await writeFile(
helperPath,
`#!/bin/sh\n/bin/sleep 600 &\nprintf '%s %s' "$$" "$!" > '${escapeSingleQuotedShell(markerPath)}'\nwait\n`,
);
await chmod(helperPath, 0o755);
const helper = await admitHelperPath(helperPath);
const repositoryPath = await createRepository(t, 'sha1');
await writeFile(join(repositoryPath, 'hello.txt'), 'timeout fixture\n');
git(repositoryPath, ['add', 'hello.txt']);
git(repositoryPath, [
'-c',
'user.name=Maka Test',
'-c',
'user.email=maka@example.invalid',
'commit',
'--quiet',
'-m',
'fixture',
]);
const expectedSourceHeadCommitOid = git(repositoryPath, ['rev-parse', 'HEAD']);
t.mock.timers.enable({ apis: ['setTimeout'] });
let settled = false;
const operation = importSourceHeadWithGitoxideHelperInternal({
...helper,
sourceRepositoryPath: repositoryPath,
expectedSourceHeadCommitOid,
destinationRepositoryPath: join(root, 'destination.git'),
baselineRef: 'refs/maka/baseline',
managedTreePolicyVersion: 3,
});
void operation.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
const [helperPid, descendantPid] = await waitForProcessMarker(markerPath);
t.after(() => {
for (const pid of [descendantPid, helperPid]) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// The timeout path already reaped the process tree.
}
}
});
t.mock.timers.tick(GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.inspectRepositoryMs);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(settled, false, 'import must not inherit the inspection deadline');
t.mock.timers.tick(
GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.importSourceHeadMs -
GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.inspectRepositoryMs,
);
await assert.rejects(
operation,
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_timed_out',
);
await Promise.all([waitForProcessExit(helperPid), waitForProcessExit(descendantPid)]);
assert.equal(isProcessAlive(helperPid), false);
assert.equal(isProcessAlive(descendantPid), false);
});
test('bounds Windows forced termination through the shared lifecycle owner', {
skip: process.platform !== 'win32',
timeout: 15_000,
}, async (t) => {
const systemRoot = process.env.SystemRoot;
if (!systemRoot) {
t.skip('SystemRoot is required for the Windows lifecycle contract test');
return;
}
const hangingExecutable = join(systemRoot, 'System32', 'charmap.exe');
try {
await stat(hangingExecutable);
} catch {
t.skip('charmap.exe is unavailable on this Windows runner');
return;
}
const helper = await admitHelperPath(hangingExecutable);
const repositoryPath = await createRepository(t, 'sha1');
const startedAt = performance.now();
await assert.rejects(
inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_timed_out',
);
assert.ok(
performance.now() - startedAt < 12_000,
'Windows process termination must acknowledge or fail within its lifecycle bound',
);
});
test('rejects an import response that does not match the requested baseline ref', {
skip: process.platform === 'win32',
}, async (t) => {
await assertMismatchedImportResponseRejected(t, {
baselineRef: 'refs/maka/not-the-requested-ref',
});
});
test('rejects an import response that does not match the requested source HEAD', {
skip: process.platform === 'win32',
}, async (t) => {
await assertMismatchedImportResponseRejected(t, {
sourceHeadCommitOid: 'b'.repeat(40),
});
});
test('rejects a candidate response whose blob identity does not match the requested bytes', {
skip: process.platform === 'win32',
}, async (t) => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'maka-gitoxide-candidate-correlation-')),
);
t.after(() => rm(root, { recursive: true, force: true }));
const repositoryPath = await createRepository(t, 'sha1');
const acceptedRef = 'refs/maka/accepted';
const candidateRef = `refs/maka/candidates/${'1'.repeat(64)}`;
const expectedBaseCommitOid = 'a'.repeat(40);
const expectedBaseTreeOid = 'b'.repeat(40);
const helperPath = join(root, 'mismatched-candidate-helper');
const response = JSON.stringify({
protocolVersion: 1,
kind: 'candidate_published',
objectFormat: 'sha1',
baseCommitOid: expectedBaseCommitOid,
baseTreeOid: expectedBaseTreeOid,
candidateCommitOid: 'c'.repeat(40),
candidateTreeOid: 'd'.repeat(40),
resultBlobOid: 'e'.repeat(40),
requestDigestSha256: candidateRequestDigestForTest({
acceptedRef,
expectedBaseCommitOid,
expectedBaseTreeOid,
candidateRef,
path: 'result.txt',
content: Buffer.from('expected bytes\n'),
}),
acceptedRef,
candidateRef,
path: 'result.txt',
managedTreePolicyVersion: 3,
});
await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`);
await chmod(helperPath, 0o755);
const helper = await admitHelperPath(helperPath, ['create_candidate']);
await assert.rejects(
createCandidateWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedRef,
expectedBaseCommitOid,
expectedBaseTreeOid,
candidateRef,
path: 'result.txt',
content: 'expected bytes\n',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_protocol_invalid',
);
});
test('rejects a direct-read response whose blob identity does not match its content', {
skip: process.platform === 'win32',
}, async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-read-correlation-')));
t.after(() => rm(root, { recursive: true, force: true }));
const repositoryPath = await createRepository(t, 'sha1');
const acceptedCommitOid = 'a'.repeat(40);
const content = 'returned bytes\n';
const helperPath = join(root, 'mismatched-read-helper');
const response = JSON.stringify({
protocolVersion: 1,
kind: 'tree_file_read',
objectFormat: 'sha1',
acceptedCommitOid,
acceptedTreeOid: 'b'.repeat(40),
blobOid: 'c'.repeat(40),
path: 'result.txt',
content,
bytesRead: Buffer.byteLength(content),
managedTreePolicyVersion: 3,
});
await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`);
await chmod(helperPath, 0o755);
const helper = await admitHelperPath(helperPath, ['read_tree_file']);
await assert.rejects(
readTreeFileWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedCommitOid,
path: 'result.txt',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_protocol_invalid',
);
});
test('keeps the Rust and TypeScript helper error protocol exhaustive', async () => {
const rustSource = await readFile(
new URL('../../../../native/gitoxide-helper/src/main.rs', import.meta.url),
'utf8',
);
const contract = /const HELPER_ERROR_REASONS_V1: &\[&str\] = &\[(?<reasons>[\s\S]*?)\];/.exec(
rustSource,
);
assert.ok(contract?.groups?.reasons, 'Rust helper error contract is missing');
const rustReasons = [...contract.groups.reasons.matchAll(/"([a-z0-9_]+)"/g)].map(
(match) => match[1],
);
assert.deepEqual(rustReasons, [...GITOXIDE_HELPER_ERROR_REASONS_V1]);
});
test('observes exact SHA-1 HEAD identity through the admitted helper capability', async (t) => {
const helper = await admittedHelper();
if (!helper) {
t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test');
return;
}
const repositoryPath = await createRepository(t, 'sha1');
await writeFile(join(repositoryPath, 'hello.txt'), 'hello from invocation owner\n');
git(repositoryPath, ['add', 'hello.txt']);
git(repositoryPath, [
'-c',
'user.name=Maka Test',
'-c',
'user.email=maka@example.invalid',
'commit',
'--quiet',
'-m',
'fixture',
]);
const expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']);
const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']);
assert.deepEqual(
await inspectRepositoryWithGitoxideHelperInternal({
...helper,
repositoryPath,
}),
{
kind: 'repository_inspected',
protocolVersion: 1,
objectFormat: 'sha1',
headCommitOid: expectedCommit,
headTreeOid: expectedTree,
},
);
});
test('returns SHA-256 as a policy rejection from the admitted helper', async (t) => {
const helper = await admittedHelper();
if (!helper) {
t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test');
return;
}
const repositoryPath = await createRepository(t, 'sha256');
assert.deepEqual(
await inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }),
{
kind: 'repository_rejected',
protocolVersion: 1,
reason: 'unsupported_object_format',
objectFormat: 'sha256',
supportedObjectFormats: ['sha1'],
},
);
});
test('normalizes an unknown object format before crossing the helper protocol', async (t) => {
const helper = await admittedHelper();
if (!helper) {
t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test');
return;
}
const repositoryPath = await createRepository(t, 'sha1');
git(repositoryPath, ['config', 'core.repositoryFormatVersion', '1']);
git(repositoryPath, ['config', 'extensions.objectFormat', 'SHA512']);
assert.deepEqual(
await inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }),
{
kind: 'repository_rejected',
protocolVersion: 1,
reason: 'unsupported_object_format',
objectFormat: 'unknown',
supportedObjectFormats: ['sha1'],
},
);
});
test('reports an unborn SHA-1 repository as a stable helper operation failure', async (t) => {
const helper = await admittedHelper();
if (!helper) {
t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test');
return;
}
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_operation_failed' &&
error.helperReason === 'head_commit_unavailable',
);
});
async function admittedHelper(): Promise<AdmittedHelper | undefined> {
if (admittedHelperPromise) return admittedHelperPromise;
admittedHelperPromise = (async () => {
const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH;
if (!configuredHelperPath) return undefined;
return admitHelperPath(configuredHelperPath);
})();
return admittedHelperPromise;
}
async function assertMismatchedImportResponseRejected(
t: TestContext,
override: {
readonly sourceHeadCommitOid?: string;
readonly baselineRef?: string;
},
): Promise<void> {
const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-correlation-')));
t.after(() => rm(root, { recursive: true, force: true }));
const repositoryPath = await createRepository(t, 'sha1');
await writeFile(join(repositoryPath, 'hello.txt'), 'response correlation fixture\n');
git(repositoryPath, ['add', 'hello.txt']);
git(repositoryPath, [
'-c',
'user.name=Maka Test',
'-c',
'user.email=maka@example.invalid',
'commit',
'--quiet',
'-m',
'fixture',
]);
const expectedSourceHeadCommitOid = git(repositoryPath, ['rev-parse', 'HEAD']);
const sourceTreeOid = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']);
const helperPath = join(root, 'mismatched-response-helper');
const response = JSON.stringify({
protocolVersion: 1,
kind: 'source_imported',
objectFormat: 'sha1',
sourceHeadCommitOid: expectedSourceHeadCommitOid,
sourceTreeOid,
baselineCommitOid: 'a'.repeat(40),
baselineTreeOid: sourceTreeOid,
baselineRef: 'refs/maka/expected-ref',
managedTreePolicyVersion: 3,
filesImported: 1,
bytesImported: 29,
...override,
});
await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`);
await chmod(helperPath, 0o755);
const helper = await admitHelperPath(helperPath);
await assert.rejects(
importSourceHeadWithGitoxideHelperInternal({
...helper,
sourceRepositoryPath: repositoryPath,
expectedSourceHeadCommitOid,
destinationRepositoryPath: join(root, 'destination.git'),
baselineRef: 'refs/maka/expected-ref',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_protocol_invalid',
);
}
async function admitHelperPath(
configuredHelperPath: string,
supportedOperations: readonly (
| 'inspect_repository'
| 'import_source_head'
| 'create_candidate'
| 'read_tree_file'
)[] = ['inspect_repository', 'import_source_head'],
): Promise<AdmittedHelper> {
const helperPath = await realpath(configuredHelperPath);
const helperBytes = await readFile(helperPath);
const helperInfo = await stat(helperPath);
const releaseOwnerToken = {};
const invocationOwnerToken = {};
const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, {
executablePath: helperPath,
expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`,
expectedBytes: helperInfo.size,
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations,
});
const capability = await admitGitoxideHelperArtifactInternal({
releaseOwnerToken,
invocationOwnerToken,
claim,
});
return { invocationOwnerToken, capability };
}
function candidateRequestDigestForTest(input: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly candidateRef: string;
readonly path: string;
readonly content: Buffer;
}): string {
const hash = createHash('sha256').update('maka.gitoxide.candidate-request.v1\0');
for (const field of [
Buffer.from(input.acceptedRef),
Buffer.from(input.expectedBaseCommitOid),
Buffer.from(input.expectedBaseTreeOid),
Buffer.from(input.candidateRef),
Buffer.from(input.path),
input.content,
]) {
const length = Buffer.alloc(8);
length.writeBigUInt64BE(BigInt(field.length));
hash.update(length).update(field);
}
return hash.digest('hex');
}
async function waitForProcessMarker(
path: string,
timeoutMs = 10_000,
): Promise<readonly [number, number]> {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
try {
const [helperPid, descendantPid, ...extra] = (await readFile(path, 'utf8'))
.trim()
.split(' ')
.map(Number);
if (
extra.length === 0 &&
helperPid !== undefined &&
descendantPid !== undefined &&
Number.isSafeInteger(helperPid) &&
Number.isSafeInteger(descendantPid)
) {
return [helperPid, descendantPid];
}
} catch {
// The helper has not published its process identity yet.
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
throw new Error('Hanging helper did not publish its process identity');
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForProcessExit(pid: number, timeoutMs = 2_000): Promise<void> {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
if (!isProcessAlive(pid)) return;
// The process-group signal has completed, but POSIX may expose a killed
// descendant as a zombie briefly while its new parent reaps it. This wait
// uses a real scheduler boundary because this test mocks only setTimeout.
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.equal(isProcessAlive(pid), false, `Process ${pid} remained observable after termination`);
}
function escapeSingleQuotedShell(value: string): string {
return value.replaceAll("'", "'\\''");
}
async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') {
const repositoryPath = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-invocation-')));
t.after(() => rm(repositoryPath, { recursive: true, force: true }));
git(repositoryPath, ['init', '--quiet', `--object-format=${objectFormat}`]);
return repositoryPath;
}
function git(cwd: string, args: readonly string[]): string {
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim();
}