Skip to content

Commit 8181b40

Browse files
committed
fix(npm): verify release archive checksums
1 parent d952d56 commit 8181b40

5 files changed

Lines changed: 97 additions & 7 deletions

File tree

.github/workflows/npm-publish.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,49 @@ jobs:
123123
"
124124
echo "Publishing coven-code@${VERSION}"
125125
126+
- name: Generate npm checksum manifest
127+
env:
128+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
129+
run: |
130+
set -euo pipefail
131+
TAG="${{ steps.version.outputs.tag }}"
132+
mkdir -p release-assets
133+
gh release download "$TAG" \
134+
--repo "${{ github.repository }}" \
135+
--pattern 'coven-code-*.tar.gz' \
136+
--pattern 'coven-code-*.zip' \
137+
--dir release-assets
138+
139+
node <<'NODE'
140+
const fs = require('fs');
141+
const path = require('path');
142+
const crypto = require('crypto');
143+
144+
const expectedArchives = [
145+
'coven-code-windows-x86_64.zip',
146+
'coven-code-linux-x86_64.tar.gz',
147+
'coven-code-linux-aarch64.tar.gz',
148+
'coven-code-macos-x86_64.tar.gz',
149+
'coven-code-macos-aarch64.tar.gz',
150+
];
151+
152+
const manifest = {};
153+
for (const archive of expectedArchives) {
154+
const archivePath = path.join('release-assets', archive);
155+
if (!fs.existsSync(archivePath)) {
156+
console.error(`::error::Missing release archive ${archive}`);
157+
process.exit(1);
158+
}
159+
const hash = crypto.createHash('sha256');
160+
hash.update(fs.readFileSync(archivePath));
161+
manifest[archive] = { sha256: hash.digest('hex') };
162+
}
163+
164+
fs.writeFileSync('npm/checksums.json', JSON.stringify(manifest, null, 2) + '\n');
165+
NODE
166+
167+
cat npm/checksums.json
168+
126169
- name: Publish to npm
127170
working-directory: npm
128171
env:

npm/bin/coven-code

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ const os = require('os');
77
const fs = require('fs');
88

99
const ext = os.platform() === 'win32' ? '.exe' : '';
10-
const binary = path.join(__dirname, '..', 'native', `coven-code`);
10+
const binary = path.join(__dirname, '..', 'native', `coven-code${ext}`);
1111

1212
if (!fs.existsSync(binary)) {
1313
console.error(
1414
'coven-code: native binary not found.\n' +
15-
'Try reinstalling: npm install -g /coven-code\n' +
15+
'Try reinstalling: npm install -g @opencoven/coven-code\n' +
1616
`Expected: ${binary}`
1717
);
1818
process.exit(1);

npm/checksums.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

npm/install.js

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
'use strict';
33

44
const https = require('https');
5-
const http = require('http');
65
const fs = require('fs');
76
const path = require('path');
87
const os = require('os');
8+
const crypto = require('crypto');
99
const { execFileSync } = require('child_process');
1010

1111
const pkg = require('./package.json');
12+
const checksums = require('./checksums.json');
1213
const VERSION = pkg.version;
1314
const REPO = 'OpenCoven/coven-code';
1415
const BASE_URL = `https://github.com/${REPO}/releases/download/v${VERSION}`;
@@ -41,13 +42,29 @@ function getPlatform() {
4142

4243
function download(url, dest) {
4344
return new Promise((resolve, reject) => {
45+
const parsed = new URL(url);
46+
if (parsed.protocol !== 'https:') {
47+
reject(new Error(`Refusing to download non-HTTPS URL: ${url}`));
48+
return;
49+
}
50+
4451
const file = fs.createWriteStream(dest);
45-
const get = url.startsWith('https') ? https : http;
46-
get.get(url, (res) => {
47-
if (res.statusCode === 301 || res.statusCode === 302) {
52+
https.get(url, (res) => {
53+
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
4854
file.close();
4955
try { fs.unlinkSync(dest); } catch (_) {}
50-
download(res.headers.location, dest).then(resolve).catch(reject);
56+
if (!res.headers.location) {
57+
reject(new Error(`Redirect without Location header downloading ${url}`));
58+
return;
59+
}
60+
let location;
61+
try {
62+
location = new URL(res.headers.location, url).toString();
63+
} catch (err) {
64+
reject(err);
65+
return;
66+
}
67+
download(location, dest).then(resolve).catch(reject);
5168
return;
5269
}
5370
if (res.statusCode !== 200) {
@@ -69,6 +86,31 @@ function download(url, dest) {
6986
});
7087
}
7188

89+
function sha256File(filePath) {
90+
const hash = crypto.createHash('sha256');
91+
const bytes = fs.readFileSync(filePath);
92+
hash.update(bytes);
93+
return hash.digest('hex');
94+
}
95+
96+
function expectedSha256(archiveName) {
97+
const entry = checksums[archiveName];
98+
if (!entry || typeof entry.sha256 !== 'string') {
99+
throw new Error(`Missing SHA-256 checksum for ${archiveName} in checksums.json`);
100+
}
101+
return entry.sha256;
102+
}
103+
104+
function verifyChecksum(filePath, archiveName) {
105+
const expected = expectedSha256(archiveName).toLowerCase();
106+
const actual = sha256File(filePath).toLowerCase();
107+
if (actual !== expected) {
108+
throw new Error(
109+
`Checksum mismatch for ${archiveName}: expected ${expected}, got ${actual}`
110+
);
111+
}
112+
}
113+
72114
async function main() {
73115
const { artifact, ext, archive } = getPlatform();
74116
const archiveName = `${artifact}${archive}`;
@@ -87,6 +129,9 @@ async function main() {
87129
console.log(` ${url}`);
88130
await download(url, tmpPath);
89131

132+
console.log('coven-code: verifying checksum...');
133+
verifyChecksum(tmpPath, archiveName);
134+
90135
console.log('coven-code: extracting...');
91136
if (archive === '.zip') {
92137
execFileSync('powershell', [

npm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"files": [
3232
"bin/",
3333
"install.js",
34+
"checksums.json",
3435
"README.md",
3536
"ATTRIBUTION.md"
3637
],

0 commit comments

Comments
 (0)