mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Verify managed installs against release checksums, serialize them with a cross-process lock, and record the compiler identity that produced the Move graph so a compiler change forces a full rebuild. Fail closed when the existing Move graph needs a compiler that is unavailable.
514 lines
19 KiB
TypeScript
514 lines
19 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import type { FileHandle } from 'node:fs/promises';
|
|
import type { ClientRequest, IncomingMessage } from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { PassThrough } from 'node:stream';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
downloadToFile,
|
|
expectedSha,
|
|
sha256File,
|
|
verifyArchiveChecksum,
|
|
} from '../../../src/core/move/artifact-download.js';
|
|
import {
|
|
acquireInstallLock,
|
|
moveFlowInstallLockWaitMs,
|
|
} from '../../../src/core/move/install-lock.js';
|
|
import {
|
|
findCachedMoveFlowBinary,
|
|
getMoveFlowInstallConfig,
|
|
installMoveFlow,
|
|
powershellExpandArchiveInvocation,
|
|
reportedMoveFlowVersion,
|
|
} from '../../../src/core/move/install.js';
|
|
|
|
const tempRoots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('move-flow installer', () => {
|
|
const assetName = 'move-flow-v2.0.0-x86_64-unknown-linux-gnu.zip';
|
|
|
|
function writeArchive(contents = 'verified move-flow archive'): string {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-checksum-'));
|
|
tempRoots.push(root);
|
|
const archive = path.join(root, assetName);
|
|
writeFileSync(archive, contents);
|
|
return archive;
|
|
}
|
|
|
|
it('accepts only an exact asset entry whose checksum matches', async () => {
|
|
const archive = writeArchive();
|
|
const digest = await sha256File(archive);
|
|
const sums = `${digest.toUpperCase()} ${assetName}\n`;
|
|
|
|
expect(expectedSha(sums, assetName)).toBe(digest);
|
|
await expect(verifyArchiveChecksum(sums, assetName, archive)).resolves.toEqual({
|
|
status: 'match',
|
|
expected: digest,
|
|
actual: digest,
|
|
});
|
|
expect(expectedSha(`SHA256 (${assetName}) = ${digest.toUpperCase()}`, assetName)).toBe(digest);
|
|
expect(expectedSha(`${digest} prefixed-${assetName}`, assetName)).toBeNull();
|
|
});
|
|
|
|
it('rejects a checksum mismatch', async () => {
|
|
const archive = writeArchive('tampered archive');
|
|
const expected = '0'.repeat(64);
|
|
const actual = await sha256File(archive);
|
|
|
|
await expect(
|
|
verifyArchiveChecksum(`${expected} ${assetName}`, assetName, archive),
|
|
).resolves.toEqual({ status: 'mismatch', expected, actual });
|
|
});
|
|
|
|
it('rejects a checksum manifest that omits the selected asset', async () => {
|
|
const archive = writeArchive();
|
|
await expect(
|
|
verifyArchiveChecksum(`${'0'.repeat(64)} another-asset.zip`, assetName, archive),
|
|
).resolves.toEqual({ status: 'missing' });
|
|
});
|
|
|
|
it('rejects a mid-download response error and removes the partial file', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-'));
|
|
tempRoots.push(root);
|
|
const destination = path.join(root, 'move-flow.zip');
|
|
const get = vi.fn((_url, _options, onResponse) => {
|
|
const request = new EventEmitter() as ClientRequest;
|
|
request.destroy = vi.fn((error?: Error) => {
|
|
if (error) request.emit('error', error);
|
|
return request;
|
|
});
|
|
queueMicrotask(() => {
|
|
const response = new PassThrough() as IncomingMessage;
|
|
response.statusCode = 200;
|
|
response.headers = {};
|
|
onResponse(response);
|
|
response.write('partial archive');
|
|
response.destroy(new Error('connection reset during download'));
|
|
});
|
|
return request;
|
|
});
|
|
|
|
await expect(
|
|
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
|
|
).rejects.toThrow('connection reset during download');
|
|
expect(existsSync(destination)).toBe(false);
|
|
expect(existsSync(`${destination}.partial`)).toBe(false);
|
|
});
|
|
|
|
it('settles the response pipeline before cleaning up a request error after headers', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-request-race-'));
|
|
tempRoots.push(root);
|
|
const destination = path.join(root, 'move-flow.zip');
|
|
let response!: PassThrough & IncomingMessage;
|
|
const get = vi.fn((_url, _options, onResponse) => {
|
|
const request = new EventEmitter() as ClientRequest;
|
|
request.destroy = vi.fn();
|
|
queueMicrotask(() => {
|
|
response = new PassThrough() as PassThrough & IncomingMessage;
|
|
response.statusCode = 200;
|
|
response.headers = {};
|
|
onResponse(response);
|
|
response.write('partial archive');
|
|
request.emit('error', new Error('request failed after headers'));
|
|
request.emit('close');
|
|
});
|
|
return request;
|
|
});
|
|
|
|
await expect(
|
|
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
|
|
).rejects.toThrow('request failed after headers');
|
|
expect(response.destroyed).toBe(true);
|
|
expect(get).toHaveBeenCalledOnce();
|
|
expect(existsSync(destination)).toBe(false);
|
|
expect(existsSync(`${destination}.partial`)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['declared', { 'content-length': '17' }],
|
|
['streamed', {}],
|
|
])('rejects %s downloads that exceed the byte limit', async (kind, headers) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-limit-'));
|
|
tempRoots.push(root);
|
|
const destination = path.join(root, 'move-flow.zip');
|
|
let response!: PassThrough & IncomingMessage;
|
|
const get = vi.fn((_url, _options, onResponse) => {
|
|
const request = new EventEmitter() as ClientRequest;
|
|
request.destroy = vi.fn();
|
|
queueMicrotask(() => {
|
|
response = new PassThrough() as PassThrough & IncomingMessage;
|
|
response.statusCode = 200;
|
|
response.headers = headers;
|
|
onResponse(response);
|
|
response.end('sixteen-byte-body');
|
|
});
|
|
return request;
|
|
});
|
|
|
|
await expect(
|
|
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get, 8),
|
|
).rejects.toThrow('download exceeds 8 bytes');
|
|
if (kind === 'declared') expect(response.destroyed).toBe(true);
|
|
expect(existsSync(destination)).toBe(false);
|
|
expect(existsSync(`${destination}.partial`)).toBe(false);
|
|
});
|
|
|
|
it('retries transient HTTP failures within the same deadline', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-retry-'));
|
|
tempRoots.push(root);
|
|
const destination = path.join(root, 'move-flow.zip');
|
|
let attempt = 0;
|
|
const get = vi.fn((_url, _options, onResponse) => {
|
|
const request = new EventEmitter() as ClientRequest;
|
|
request.destroy = vi.fn((error?: Error) => {
|
|
if (error) request.emit('error', error);
|
|
return request;
|
|
});
|
|
queueMicrotask(() => {
|
|
const response = new PassThrough() as IncomingMessage;
|
|
response.statusCode = attempt++ === 0 ? 503 : 200;
|
|
response.headers = {};
|
|
onResponse(response);
|
|
response.end(response.statusCode === 200 ? 'verified archive' : undefined);
|
|
request.emit('close');
|
|
});
|
|
return request;
|
|
});
|
|
|
|
await expect(
|
|
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
|
|
).resolves.toBeUndefined();
|
|
expect(get).toHaveBeenCalledTimes(2);
|
|
expect(readFileSync(destination, 'utf8')).toBe('verified archive');
|
|
});
|
|
|
|
it('rejects redirects that downgrade HTTPS', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-redirect-'));
|
|
tempRoots.push(root);
|
|
const destination = path.join(root, 'move-flow.zip');
|
|
const get = vi.fn((_url, _options, onResponse) => {
|
|
const request = new EventEmitter() as ClientRequest;
|
|
request.destroy = vi.fn();
|
|
queueMicrotask(() => {
|
|
const response = new PassThrough() as IncomingMessage;
|
|
response.statusCode = 302;
|
|
response.headers = { location: 'http://example.test/insecure.zip' };
|
|
onResponse(response);
|
|
request.emit('close');
|
|
});
|
|
return request;
|
|
});
|
|
|
|
await expect(
|
|
downloadToFile('https://example.test/move-flow.zip', destination, 1_000, get),
|
|
).rejects.toThrow('refusing non-HTTPS redirect');
|
|
expect(get).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects an initial non-HTTPS artifact URL before opening a request', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-download-http-'));
|
|
tempRoots.push(root);
|
|
const get = vi.fn();
|
|
|
|
await expect(
|
|
downloadToFile(
|
|
'http://example.test/move-flow.zip',
|
|
path.join(root, 'move-flow.zip'),
|
|
1_000,
|
|
get,
|
|
),
|
|
).rejects.toThrow('downloads require HTTPS');
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'rejects a hash-valid cache whose execute bit was lost',
|
|
async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-non-executable-'));
|
|
tempRoots.push(root);
|
|
const env = {
|
|
...process.env,
|
|
GITNEXUS_HOME: root,
|
|
GITNEXUS_SKIP_MOVE_FLOW: '0',
|
|
};
|
|
const config = await getMoveFlowInstallConfig(env);
|
|
expect(config).not.toBeNull();
|
|
if (!config) throw new Error('expected a supported platform');
|
|
mkdirSync(config.installDir, { recursive: true });
|
|
writeFileSync(config.binaryPath, 'cached move-flow');
|
|
chmodSync(config.binaryPath, 0o644);
|
|
writeFileSync(
|
|
config.metadataPath,
|
|
JSON.stringify({
|
|
version: config.version,
|
|
repository: config.repository,
|
|
tag: config.tag,
|
|
assetName: config.assetName,
|
|
binarySha256: await sha256File(config.binaryPath),
|
|
}),
|
|
);
|
|
|
|
await expect(findCachedMoveFlowBinary(env)).resolves.toBeNull();
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'returns the verified descriptor for a live verified cache',
|
|
async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-valid-cache-'));
|
|
tempRoots.push(root);
|
|
const env = { ...process.env, GITNEXUS_HOME: root, GITNEXUS_SKIP_MOVE_FLOW: '0' };
|
|
const config = await getMoveFlowInstallConfig(env);
|
|
if (!config) throw new Error('expected a supported platform');
|
|
mkdirSync(config.installDir, { recursive: true });
|
|
writeFileSync(config.binaryPath, '#!/bin/sh\nprintf "move-flow 2.0.0\\n"\n');
|
|
chmodSync(config.binaryPath, 0o755);
|
|
writeFileSync(
|
|
config.metadataPath,
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
version: config.version,
|
|
repository: config.repository,
|
|
tag: config.tag,
|
|
assetName: config.assetName,
|
|
archiveSha256: '0'.repeat(64),
|
|
binarySha256: await sha256File(config.binaryPath),
|
|
}),
|
|
);
|
|
|
|
await expect(findCachedMoveFlowBinary(env)).resolves.toMatchObject({
|
|
binaryPath: config.binaryPath,
|
|
version: '2.0.0',
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === 'win32')('rejects a symlinked cached binary', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-symlink-cache-'));
|
|
tempRoots.push(root);
|
|
const env = { ...process.env, GITNEXUS_HOME: root, GITNEXUS_SKIP_MOVE_FLOW: '0' };
|
|
const config = await getMoveFlowInstallConfig(env);
|
|
if (!config) throw new Error('expected a supported platform');
|
|
mkdirSync(config.installDir, { recursive: true });
|
|
const target = path.join(root, 'move-flow-target');
|
|
writeFileSync(target, '#!/bin/sh\nprintf "move-flow 2.0.0\\n"\n');
|
|
chmodSync(target, 0o755);
|
|
symlinkSync(target, config.binaryPath);
|
|
writeFileSync(
|
|
config.metadataPath,
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
version: config.version,
|
|
repository: config.repository,
|
|
tag: config.tag,
|
|
assetName: config.assetName,
|
|
archiveSha256: '0'.repeat(64),
|
|
binarySha256: await sha256File(target),
|
|
}),
|
|
);
|
|
|
|
await expect(findCachedMoveFlowBinary(env)).resolves.toBeNull();
|
|
});
|
|
|
|
it('rejects oversized cache metadata before parsing it', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-large-metadata-'));
|
|
tempRoots.push(root);
|
|
const env = { ...process.env, GITNEXUS_HOME: root, GITNEXUS_SKIP_MOVE_FLOW: '0' };
|
|
const config = await getMoveFlowInstallConfig(env);
|
|
if (!config) throw new Error('expected a supported platform');
|
|
mkdirSync(config.installDir, { recursive: true });
|
|
writeFileSync(config.metadataPath, 'x'.repeat(65 * 1024));
|
|
|
|
await expect(findCachedMoveFlowBinary(env)).resolves.toBeNull();
|
|
});
|
|
|
|
it('passes PowerShell paths as environment data instead of command source', () => {
|
|
const archive = `C:\\temp\\move flow's "archive".zip`;
|
|
const destination = `C:\\temp\\destination's folder`;
|
|
const invocation = powershellExpandArchiveInvocation(archive, destination);
|
|
const command = invocation.args.join(' ');
|
|
|
|
expect(command).toContain('$env:GITNEXUS_MOVE_FLOW_ARCHIVE_PATH');
|
|
expect(command).toContain('$env:GITNEXUS_MOVE_FLOW_DESTINATION_PATH');
|
|
expect(command).not.toContain(archive);
|
|
expect(command).not.toContain(destination);
|
|
expect(invocation.env.GITNEXUS_MOVE_FLOW_ARCHIVE_PATH).toBe(archive);
|
|
expect(invocation.env.GITNEXUS_MOVE_FLOW_DESTINATION_PATH).toBe(destination);
|
|
});
|
|
|
|
it('uses an artifact-identified versioned user cache', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-home-'));
|
|
tempRoots.push(root);
|
|
const config = await getMoveFlowInstallConfig({
|
|
...process.env,
|
|
GITNEXUS_HOME: root,
|
|
GITNEXUS_MOVE_FLOW_COMPAT: '1',
|
|
});
|
|
|
|
expect(config).not.toBeNull();
|
|
expect(config?.installDir.startsWith(path.join(root, 'tools', 'move-flow', '2.0.0'))).toBe(
|
|
true,
|
|
);
|
|
expect(path.basename(config?.installDir ?? '')).toMatch(
|
|
/^(linux-x64|linux-arm64|darwin-arm64|darwin-x64|win32-x64)-[0-9a-f]{12}$/,
|
|
);
|
|
if (process.platform === 'linux') expect(config?.releaseTarget).toContain('compat');
|
|
});
|
|
|
|
it('keeps compatible release coordinates dynamic', async () => {
|
|
const config = await getMoveFlowInstallConfig({
|
|
...process.env,
|
|
GITNEXUS_MOVE_FLOW_VERSION: '2.7.9',
|
|
GITNEXUS_MOVE_FLOW_REPO: 'example/move-flow',
|
|
GITNEXUS_MOVE_FLOW_TAG: 'release-2.7.9',
|
|
});
|
|
|
|
expect(config).toMatchObject({
|
|
version: '2.7.9',
|
|
repository: 'example/move-flow',
|
|
tag: 'release-2.7.9',
|
|
});
|
|
});
|
|
|
|
it('rejects an incompatible configured major before downloading', async () => {
|
|
await expect(
|
|
getMoveFlowInstallConfig({ ...process.env, GITNEXUS_MOVE_FLOW_VERSION: '3.0.0' }),
|
|
).rejects.toThrow('unsupported move-flow major version');
|
|
});
|
|
|
|
it('keeps install waiters alive beyond the full download budget', () => {
|
|
expect(moveFlowInstallLockWaitMs(30_000)).toBe(120_000);
|
|
expect(moveFlowInstallLockWaitMs(90_000)).toBe(240_000);
|
|
});
|
|
|
|
it('matches the reported semantic version exactly', () => {
|
|
expect(reportedMoveFlowVersion('move-flow 2.0.0')).toBe('2.0.0');
|
|
expect(reportedMoveFlowVersion('move-flow 12.0.0')).not.toBe('2.0.0');
|
|
});
|
|
|
|
it.each([
|
|
['GITNEXUS_MOVE_FLOW_VERSION', '../escape'],
|
|
['GITNEXUS_MOVE_FLOW_TAG', '../../outside'],
|
|
['GITNEXUS_MOVE_FLOW_REPO', 'owner/repo/extra'],
|
|
])('rejects unsafe release coordinate %s', async (key, value) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-invalid-config-'));
|
|
tempRoots.push(root);
|
|
const env = {
|
|
...process.env,
|
|
// Cross-platform CI disables automatic provisioning globally; this case
|
|
// must still reach release-coordinate validation.
|
|
GITNEXUS_SKIP_MOVE_FLOW: '0',
|
|
GITNEXUS_MOVE_FLOW_DIR: root,
|
|
[key]: value,
|
|
};
|
|
|
|
await expect(getMoveFlowInstallConfig(env)).rejects.toThrow('invalid move-flow');
|
|
await expect(installMoveFlow(env)).resolves.toMatchObject({ status: 'failed' });
|
|
expect(existsSync(path.join(root, 'escape'))).toBe(false);
|
|
});
|
|
|
|
it.each(['GITNEXUS_SKIP_MOVE_FLOW', 'GITNEXUS_SKIP_OPTIONAL_GRAMMARS'] as const)(
|
|
'%s=1 returns a structured skip result without touching the network',
|
|
async (flag) => {
|
|
const env = {
|
|
...process.env,
|
|
GITNEXUS_SKIP_MOVE_FLOW: '0',
|
|
GITNEXUS_SKIP_OPTIONAL_GRAMMARS: '0',
|
|
[flag]: '1',
|
|
};
|
|
await expect(installMoveFlow(env)).resolves.toMatchObject({
|
|
status: 'skipped',
|
|
message: expect.stringContaining(flag),
|
|
});
|
|
},
|
|
);
|
|
|
|
it('serializes concurrent installers and transfers lock ownership safely', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-lock-'));
|
|
tempRoots.push(root);
|
|
const lockPath = path.join(root, 'move-flow.install.lock');
|
|
const options = { waitTimeoutMs: 2_000, leaseMs: 500, heartbeatMs: 20, retryMs: 10 };
|
|
const releaseFirst = await acquireInstallLock(lockPath, options);
|
|
let secondAcquired = false;
|
|
const second = acquireInstallLock(lockPath, options).then((release) => {
|
|
secondAcquired = true;
|
|
return release;
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
expect(secondAcquired).toBe(false);
|
|
await releaseFirst();
|
|
const releaseSecond = await second;
|
|
expect(secondAcquired).toBe(true);
|
|
expect(existsSync(lockPath)).toBe(true);
|
|
await releaseSecond();
|
|
expect(existsSync(lockPath)).toBe(false);
|
|
});
|
|
|
|
it('removes a lock when writing its ownership token fails', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-lock-write-'));
|
|
tempRoots.push(root);
|
|
const lockPath = path.join(root, 'move-flow.install.lock');
|
|
const handle = {
|
|
writeFile: vi.fn(async () => {
|
|
throw new Error('simulated disk failure');
|
|
}),
|
|
close: vi.fn(async () => {}),
|
|
} as unknown as FileHandle;
|
|
const removeLock = vi.fn(async (file: string) => {
|
|
rmSync(file, { force: true });
|
|
});
|
|
|
|
await expect(
|
|
acquireInstallLock(
|
|
lockPath,
|
|
{ waitTimeoutMs: 100 },
|
|
{
|
|
openLock: async () => {
|
|
writeFileSync(lockPath, '');
|
|
return handle;
|
|
},
|
|
removeLock,
|
|
},
|
|
),
|
|
).rejects.toThrow('simulated disk failure');
|
|
expect(handle.close).toHaveBeenCalledOnce();
|
|
expect(removeLock).toHaveBeenCalledWith(lockPath);
|
|
expect(existsSync(lockPath)).toBe(false);
|
|
});
|
|
|
|
it('reclaims a stable malformed lock after its lease expires', async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'gitnexus-move-flow-lock-stale-'));
|
|
tempRoots.push(root);
|
|
const lockPath = path.join(root, 'move-flow.install.lock');
|
|
writeFileSync(lockPath, 'not-json');
|
|
const old = new Date(Date.now() - 10_000);
|
|
utimesSync(lockPath, old, old);
|
|
|
|
const release = await acquireInstallLock(lockPath, {
|
|
waitTimeoutMs: 1_000,
|
|
leaseMs: 50,
|
|
heartbeatMs: 10,
|
|
retryMs: 5,
|
|
});
|
|
expect(existsSync(lockPath)).toBe(true);
|
|
await release();
|
|
expect(existsSync(lockPath)).toBe(false);
|
|
});
|
|
});
|