From 189d2b3e84b8842f3a79011bb88f6f712773d90d Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sun, 16 Nov 2025 10:23:35 +0100 Subject: [PATCH] Verify win32 manifest hashes after normalization --- .../scripts/normalize-win32-artifacts.mjs | 112 ++++++++++++++++++ package.json | 5 +- tests/normalizeWin32Artifacts.test.ts | 77 ++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/normalizeWin32Artifacts.test.ts diff --git a/electron/scripts/normalize-win32-artifacts.mjs b/electron/scripts/normalize-win32-artifacts.mjs index a0a7883..8d998ce 100755 --- a/electron/scripts/normalize-win32-artifacts.mjs +++ b/electron/scripts/normalize-win32-artifacts.mjs @@ -41,6 +41,103 @@ const toBasename = (value) => { return path.basename(value.trim()); }; +const collectSha512Mismatches = (lines, hashMap) => { + let inFilesSection = false; + let inPackagesSection = false; + let currentFileKey = null; + let currentPackageFileKey = null; + let rootFileKey = null; + const mismatches = []; + + const checkHash = (line, key) => { + if (!key) { + return; + } + const expected = hashMap.get(key); + if (!expected) { + return; + } + const match = line.match(/sha512:\s+(.+)/); + if (!match) { + return; + } + const actual = match[1].trim(); + if (actual !== expected) { + mismatches.push({ key, expected, actual }); + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === 'files:') { + inFilesSection = true; + inPackagesSection = false; + currentFileKey = null; + continue; + } + if (trimmed === 'packages:') { + inFilesSection = false; + inPackagesSection = true; + currentPackageFileKey = null; + continue; + } + if (!line.startsWith(' ')) { + inFilesSection = trimmed === '' ? inFilesSection : false; + if (trimmed !== 'packages:') { + inPackagesSection = false; + } + } + + if (inFilesSection) { + const urlMatch = line.match(/^\s*-\s+url:\s+(.*)$/); + if (urlMatch) { + currentFileKey = toBasename(urlMatch[1]); + continue; + } + + const pathMatch = line.match(/^\s+path:\s+(.*)$/); + if (pathMatch) { + currentFileKey = toBasename(pathMatch[1]); + continue; + } + + if (/^\s+sha512:\s+/.test(line)) { + checkHash(line, currentFileKey); + continue; + } + continue; + } + + if (inPackagesSection) { + const packagePathMatch = line.match(/^\s{4}path:\s+(.*)$/); + if (packagePathMatch) { + currentPackageFileKey = toBasename(packagePathMatch[1]); + continue; + } + + if (/^\s{4}sha512:\s+/.test(line)) { + checkHash(line, currentPackageFileKey); + continue; + } + continue; + } + + const rootPathMatch = line.match(/^path:\s+(.*)$/); + if (rootPathMatch) { + rootFileKey = toBasename(rootPathMatch[1]); + continue; + } + + if (/^sha512:\s+/.test(line)) { + checkHash(line, rootFileKey); + continue; + } + } + + return mismatches; +}; + const updateManifestFile = async (manifestPath, renameMap, hashMap) => { const raw = await fsPromises.readFile(manifestPath, 'utf8'); const lines = raw.split(/\r?\n/); @@ -177,6 +274,17 @@ const updateManifestFile = async (manifestPath, renameMap, hashMap) => { } }; +const assertManifestIntegrity = async (manifestPath, hashMap) => { + const raw = await fsPromises.readFile(manifestPath, 'utf8'); + const mismatches = collectSha512Mismatches(raw.split(/\r?\n/), hashMap); + if (mismatches.length > 0) { + const error = new Error(`Manifest ${path.basename(manifestPath)} has mismatched sha512 entries.`); + error.mismatches = mismatches; + throw error; + } + log('Manifest integrity verified.', { manifest: path.basename(manifestPath) }); +}; + const updateName = (name) => name.replace(/-ia32-/gi, '-win32-'); const main = async () => { @@ -256,6 +364,10 @@ const main = async () => { for (const manifest of manifestsToUpdate) { await updateManifestFile(manifest, renameMap, hashes); } + + for (const manifest of manifestsToUpdate) { + await assertManifestIntegrity(manifest, hashes); + } }; main().catch(error => { diff --git a/package.json b/package.json index 5fe00d6..5641f1d 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "watch": "node esbuild.config.js --watch", "electron": "wait-on dist/main.js dist/renderer.js dist/preload.js && electron .", "build": "cross-env NODE_ENV=production node esbuild.config.js", - "pack": "npm run build && electron-builder --publish never", - "publish": "npm run build && electron-builder --publish always", + "pack": "npm run build && electron-builder --publish never && npm run normalize:win32", + "publish": "npm run build && electron-builder --publish always && npm run normalize:win32", + "normalize:win32": "node electron/scripts/normalize-win32-artifacts.mjs", "test": "rm -rf build-tests && tsc -p tsconfig.test.json && node --test build-tests/tests" }, "dependencies": { diff --git a/tests/normalizeWin32Artifacts.test.ts b/tests/normalizeWin32Artifacts.test.ts new file mode 100644 index 0000000..a2a7ba3 --- /dev/null +++ b/tests/normalizeWin32Artifacts.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { promisify } from 'node:util'; +import { execFile } from 'node:child_process'; + +const execFileAsync = promisify(execFile); +const scriptPath = path.resolve( + __dirname, + '..', + '..', + 'electron', + 'scripts', + 'normalize-win32-artifacts.mjs', +); + +const createTempRelease = async () => { + const root = await mkdtemp(path.join(tmpdir(), 'normalize-win32-')); + const releaseDir = path.join(root, 'release'); + await mkdir(releaseDir, { recursive: true }); + return { root, releaseDir }; +}; + +test('normalize script renames win32 artifacts and refreshes manifest hashes', async () => { + const { root, releaseDir } = await createTempRelease(); + try { + const exeName = 'git-automation-dashboard-0.26.0-windows-ia32-setup.exe'; + const blockmapName = `${exeName}.blockmap`; + const renamedExe = exeName.replace('-ia32-', '-win32-'); + const renamedBlockmap = blockmapName.replace('-ia32-', '-win32-'); + + const exeContent = Buffer.from('dummy-win32-binary'); + const blockmapContent = Buffer.from('dummy-blockmap'); + + await writeFile(path.join(releaseDir, exeName), exeContent); + await writeFile(path.join(releaseDir, blockmapName), blockmapContent); + + const manifest = `version: 0.26.0\nfiles:\n - url: ${exeName}\n sha512: INVALID\n size: ${exeContent.length}\n - url: ${blockmapName}\n sha512: ALSO-INVALID\n size: ${blockmapContent.length}\npath: ${exeName}\nsha512: WRONG\nreleaseDate: 2025-01-01T00:00:00.000Z\n`; + await writeFile(path.join(releaseDir, 'latest.yml'), manifest, 'utf8'); + + await execFileAsync('node', [scriptPath], { cwd: root }); + + const renamedExePath = path.join(releaseDir, renamedExe); + const renamedBlockmapPath = path.join(releaseDir, renamedBlockmap); + + await assert.rejects(readFile(path.join(releaseDir, exeName)), (error: any) => error?.code === 'ENOENT'); + await assert.rejects(readFile(path.join(releaseDir, blockmapName)), (error: any) => error?.code === 'ENOENT'); + + const updatedManifest = await readFile(path.join(releaseDir, 'latest.yml'), 'utf8'); + const win32Manifest = await readFile(path.join(releaseDir, 'latest-win32.yml'), 'utf8'); + + const exeHash = crypto.createHash('sha512').update(exeContent).digest('base64'); + const blockmapHash = crypto.createHash('sha512').update(blockmapContent).digest('base64'); + + assert.ok(updatedManifest.includes(renamedExe)); + assert.ok(updatedManifest.includes(renamedBlockmap)); + assert.ok(updatedManifest.includes(`sha512: ${exeHash}`)); + assert.ok(updatedManifest.includes(`sha512: ${blockmapHash}`)); + assert.equal(updatedManifest, win32Manifest); + + const before = updatedManifest; + await execFileAsync('node', [scriptPath], { cwd: root }); + const after = await readFile(path.join(releaseDir, 'latest.yml'), 'utf8'); + assert.equal(after, before, 'script should be idempotent when run again'); + + // Ensure renamed files still exist after idempotent run + const renamedExeContent = await readFile(renamedExePath); + const renamedBlockmapContent = await readFile(renamedBlockmapPath); + assert.equal(renamedExeContent.toString(), exeContent.toString()); + assert.equal(renamedBlockmapContent.toString(), blockmapContent.toString()); + } finally { + await rm(root, { recursive: true, force: true }); + } +});