From edb8afb726261ccd39c44305f901b7bec123467d Mon Sep 17 00:00:00 2001 From: zwxxb Date: Tue, 21 Jul 2026 10:55:56 +0200 Subject: [PATCH] fix(move): harden installer checksum matching --- gitnexus/scripts/install-move-flow.cjs | 35 +++++++--- .../test/unit/move/install-move-flow.test.ts | 66 ++++++++++++++++++- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/gitnexus/scripts/install-move-flow.cjs b/gitnexus/scripts/install-move-flow.cjs index 29fe1035a..24d83f82f 100644 --- a/gitnexus/scripts/install-move-flow.cjs +++ b/gitnexus/scripts/install-move-flow.cjs @@ -244,13 +244,22 @@ function expectedSha(sumsText, assetName) { if (!line) continue; // Format: " " (BSD-style "SHA256 (file) = " 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) => { diff --git a/gitnexus/test/unit/move/install-move-flow.test.ts b/gitnexus/test/unit/move/install-move-flow.test.ts index 2b1e35ace..a560e63c8 100644 --- a/gitnexus/test/unit/move/install-move-flow.test.ts +++ b/gitnexus/test/unit/move/install-move-flow.test.ts @@ -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; + 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);