GitNexus/gitnexus/test/unit/move/install-move-flow.test.ts
zwxxb 40abd33325 fix(move): track compiler identity for managed move-flow installs
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.
2026-07-21 21:35:34 +02:00

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);
});
});