fix(move): harden installer checksum matching
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user