fix(move): harden installer checksum matching

This commit is contained in:
zwxxb
2026-07-21 10:55:56 +02:00
parent 31933c706a
commit edb8afb726
2 changed files with 89 additions and 12 deletions
+26 -9
View File
@@ -244,13 +244,22 @@ function expectedSha(sumsText, assetName) {
if (!line) continue;
// Format: "<sha256> <filename>" (BSD-style "SHA256 (file) = <hex>" also tolerated).
const m = /^([0-9a-f]{64})[ \t*]+(\S.*)$/i.exec(line);
if (m && m[2].endsWith(assetName)) return m[1].toLowerCase();
if (m && m[2] === assetName) return m[1].toLowerCase();
const bsd = /^SHA256\s*\((.*)\)\s*=\s*([0-9a-f]{64})$/i.exec(line);
if (bsd && bsd[1].endsWith(assetName)) return bsd[2].toLowerCase();
if (bsd && bsd[1] === assetName) return bsd[2].toLowerCase();
}
return null;
}
function verifyArchiveChecksum(sumsText, assetName, archive) {
const expected = expectedSha(sumsText, assetName);
if (!expected) return { status: 'missing' };
const actual = sha256(archive);
if (actual !== expected) return { status: 'mismatch', expected, actual };
return { status: 'match', expected, actual };
}
async function main() {
for (const flag of SKIP_FLAGS) {
if (process.env[flag] === '1') {
@@ -288,9 +297,12 @@ async function main() {
await downloadToFile(`${RELEASE_BASE}/${sumsName}`, tmpSums);
await downloadToFile(`${RELEASE_BASE}/${assetName}`, tmpArchive);
const sums = fs.readFileSync(tmpSums, 'utf8');
const expected = expectedSha(sums, assetName);
if (!expected) {
const verification = verifyArchiveChecksum(
fs.readFileSync(tmpSums, 'utf8'),
assetName,
tmpArchive,
);
if (verification.status === 'missing') {
console.warn(
`[move-flow] ${sumsName} does not list ${assetName} — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
@@ -298,10 +310,9 @@ async function main() {
process.exit(0);
}
const actual = sha256(tmpArchive);
if (actual !== expected) {
if (verification.status === 'mismatch') {
console.warn(
`[move-flow] Checksum mismatch for ${assetName} (expected ${expected}, got ${actual}) — refusing to install. ` +
`[move-flow] Checksum mismatch for ${assetName} (expected ${verification.expected}, got ${verification.actual}) — refusing to install. ` +
'Move ingestion will be unavailable. Non-Move functionality is unaffected.',
);
process.exit(0);
@@ -343,7 +354,13 @@ async function main() {
}
}
module.exports = { downloadToFile, powershellExpandArchiveInvocation };
module.exports = {
downloadToFile,
expectedSha,
sha256,
verifyArchiveChecksum,
powershellExpandArchiveInvocation,
};
if (require.main === module) {
main().catch((err) => {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRequire } from 'node:module';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -13,8 +13,16 @@ interface DownloadResponse extends PassThrough {
type DownloadGet = (url: string, onResponse: (response: DownloadResponse) => void) => EventEmitter;
type ChecksumVerification =
| { status: 'match'; expected: string; actual: string }
| { status: 'mismatch'; expected: string; actual: string }
| { status: 'missing' };
interface InstallerHelpers {
downloadToFile(url: string, dest: string, get?: DownloadGet): Promise<void>;
expectedSha(sumsText: string, assetName: string): string | null;
sha256(file: string): string;
verifyArchiveChecksum(sumsText: string, assetName: string, archive: string): ChecksumVerification;
powershellExpandArchiveInvocation(
archive: string,
dest: string,
@@ -25,8 +33,13 @@ interface InstallerHelpers {
}
const require = createRequire(import.meta.url);
const { downloadToFile, powershellExpandArchiveInvocation } =
require('../../../scripts/install-move-flow.cjs') as InstallerHelpers;
const {
downloadToFile,
expectedSha,
sha256,
verifyArchiveChecksum,
powershellExpandArchiveInvocation,
} = require('../../../scripts/install-move-flow.cjs') as InstallerHelpers;
const tempRoots: string[] = [];
@@ -37,6 +50,53 @@ afterEach(() => {
});
describe('install-move-flow', () => {
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 an exact asset entry whose checksum matches the downloaded archive', () => {
const archive = writeArchive();
const digest = sha256(archive);
const sums = `${digest.toUpperCase()} ${assetName}\n`;
expect(expectedSha(sums, assetName)).toBe(digest);
expect(verifyArchiveChecksum(sums, assetName, archive)).toEqual({
status: 'match',
expected: digest,
actual: digest,
});
// Preserve the release parser's supported BSD checksum format too.
expect(expectedSha(`SHA256 (${assetName}) = ${digest.toUpperCase()}`, assetName)).toBe(digest);
});
it('reports a checksum mismatch instead of authorizing the archive', () => {
const archive = writeArchive('tampered archive');
const expected = '0'.repeat(64);
const actual = sha256(archive);
expect(verifyArchiveChecksum(`${expected} ${assetName}`, assetName, archive)).toEqual({
status: 'mismatch',
expected,
actual,
});
});
it('treats a suffix-collision entry as an omitted asset', () => {
const archive = writeArchive();
const digest = sha256(archive);
const sums = `${digest} prefixed-${assetName}`;
expect(expectedSha(sums, assetName)).toBeNull();
expect(expectedSha(`SHA256 (prefixed-${assetName}) = ${digest}`, assetName)).toBeNull();
expect(verifyArchiveChecksum(sums, assetName, archive)).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);