From 8181b406dbb505fb8dd3b83369eb9a0909b80cd4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 5 Jun 2026 16:58:24 -0700 Subject: [PATCH] fix(npm): verify release archive checksums --- .github/workflows/npm-publish.yml | 43 ++++++++++++++++++++++++ npm/bin/coven-code | 4 +-- npm/checksums.json | 1 + npm/install.js | 55 ++++++++++++++++++++++++++++--- npm/package.json | 1 + 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 npm/checksums.json diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 8555320..6a83abe 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -123,6 +123,49 @@ jobs: " echo "Publishing coven-code@${VERSION}" + - name: Generate npm checksum manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.version.outputs.tag }}" + mkdir -p release-assets + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern 'coven-code-*.tar.gz' \ + --pattern 'coven-code-*.zip' \ + --dir release-assets + + node <<'NODE' + const fs = require('fs'); + const path = require('path'); + const crypto = require('crypto'); + + const expectedArchives = [ + 'coven-code-windows-x86_64.zip', + 'coven-code-linux-x86_64.tar.gz', + 'coven-code-linux-aarch64.tar.gz', + 'coven-code-macos-x86_64.tar.gz', + 'coven-code-macos-aarch64.tar.gz', + ]; + + const manifest = {}; + for (const archive of expectedArchives) { + const archivePath = path.join('release-assets', archive); + if (!fs.existsSync(archivePath)) { + console.error(`::error::Missing release archive ${archive}`); + process.exit(1); + } + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(archivePath)); + manifest[archive] = { sha256: hash.digest('hex') }; + } + + fs.writeFileSync('npm/checksums.json', JSON.stringify(manifest, null, 2) + '\n'); + NODE + + cat npm/checksums.json + - name: Publish to npm working-directory: npm env: diff --git a/npm/bin/coven-code b/npm/bin/coven-code index 6f5af8d..c2df1ac 100644 --- a/npm/bin/coven-code +++ b/npm/bin/coven-code @@ -7,12 +7,12 @@ const os = require('os'); const fs = require('fs'); const ext = os.platform() === 'win32' ? '.exe' : ''; -const binary = path.join(__dirname, '..', 'native', `coven-code`); +const binary = path.join(__dirname, '..', 'native', `coven-code${ext}`); if (!fs.existsSync(binary)) { console.error( 'coven-code: native binary not found.\n' + - 'Try reinstalling: npm install -g /coven-code\n' + + 'Try reinstalling: npm install -g @opencoven/coven-code\n' + `Expected: ${binary}` ); process.exit(1); diff --git a/npm/checksums.json b/npm/checksums.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/npm/checksums.json @@ -0,0 +1 @@ +{} diff --git a/npm/install.js b/npm/install.js index 0059f87..61b90bb 100644 --- a/npm/install.js +++ b/npm/install.js @@ -2,13 +2,14 @@ 'use strict'; const https = require('https'); -const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const crypto = require('crypto'); const { execFileSync } = require('child_process'); const pkg = require('./package.json'); +const checksums = require('./checksums.json'); const VERSION = pkg.version; const REPO = 'OpenCoven/coven-code'; const BASE_URL = `https://github.com/${REPO}/releases/download/v${VERSION}`; @@ -41,13 +42,29 @@ function getPlatform() { function download(url, dest) { return new Promise((resolve, reject) => { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + reject(new Error(`Refusing to download non-HTTPS URL: ${url}`)); + return; + } + const file = fs.createWriteStream(dest); - const get = url.startsWith('https') ? https : http; - get.get(url, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { + https.get(url, (res) => { + if ([301, 302, 303, 307, 308].includes(res.statusCode)) { file.close(); try { fs.unlinkSync(dest); } catch (_) {} - download(res.headers.location, dest).then(resolve).catch(reject); + if (!res.headers.location) { + reject(new Error(`Redirect without Location header downloading ${url}`)); + return; + } + let location; + try { + location = new URL(res.headers.location, url).toString(); + } catch (err) { + reject(err); + return; + } + download(location, dest).then(resolve).catch(reject); return; } if (res.statusCode !== 200) { @@ -69,6 +86,31 @@ function download(url, dest) { }); } +function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const bytes = fs.readFileSync(filePath); + hash.update(bytes); + return hash.digest('hex'); +} + +function expectedSha256(archiveName) { + const entry = checksums[archiveName]; + if (!entry || typeof entry.sha256 !== 'string') { + throw new Error(`Missing SHA-256 checksum for ${archiveName} in checksums.json`); + } + return entry.sha256; +} + +function verifyChecksum(filePath, archiveName) { + const expected = expectedSha256(archiveName).toLowerCase(); + const actual = sha256File(filePath).toLowerCase(); + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${archiveName}: expected ${expected}, got ${actual}` + ); + } +} + async function main() { const { artifact, ext, archive } = getPlatform(); const archiveName = `${artifact}${archive}`; @@ -87,6 +129,9 @@ async function main() { console.log(` ${url}`); await download(url, tmpPath); + console.log('coven-code: verifying checksum...'); + verifyChecksum(tmpPath, archiveName); + console.log('coven-code: extracting...'); if (archive === '.zip') { execFileSync('powershell', [ diff --git a/npm/package.json b/npm/package.json index c762318..ebf8126 100644 --- a/npm/package.json +++ b/npm/package.json @@ -31,6 +31,7 @@ "files": [ "bin/", "install.js", + "checksums.json", "README.md", "ATTRIBUTION.md" ],