diff --git a/npm/postinstall.js b/npm/postinstall.js index 07bf79b..8c7a6b5 100644 --- a/npm/postinstall.js +++ b/npm/postinstall.js @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); const https = require('https'); -const zlib = require('zlib'); +const crypto = require('crypto'); const { pipeline } = require('stream'); const { promisify } = require('util'); const { execFileSync } = require('child_process'); @@ -40,7 +40,10 @@ if (!target) { process.exit(1); } -const url = `https://github.com/optiqor/optiqor-cli/releases/download/v${VERSION}/optiqor_${VERSION}_${target}.tar.gz`; +const releaseBaseUrl = `https://github.com/optiqor/optiqor-cli/releases/download/v${VERSION}`; +const archiveName = `optiqor_${VERSION}_${target}.tar.gz`; +const url = `${releaseBaseUrl}/${archiveName}`; +const checksumsUrl = `${releaseBaseUrl}/checksums.txt`; const vendorDir = path.join(__dirname, '..', 'vendor'); fs.mkdirSync(vendorDir, { recursive: true }); @@ -63,20 +66,101 @@ const get = (u) => const pipelineP = promisify(pipeline); -(async () => { +class FatalInstallError extends Error { + constructor(message) { + super(message); + this.name = 'FatalInstallError'; + } +} + +const unlinkIfExists = (filePath) => { + try { + fs.unlinkSync(filePath); + } catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + } +}; + +const responseText = async (res) => { + const chunks = []; + for await (const chunk of res) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const checksumForArchive = (checksumsText, name) => { + for (const rawLine of checksumsText.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (match && path.basename(match[2].trim()) === name) { + return match[1].toLowerCase(); + } + } + return null; +}; + +const sha256File = (filePath) => + new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + fs.createReadStream(filePath) + .on('data', (chunk) => hash.update(chunk)) + .on('error', reject) + .on('end', () => resolve(hash.digest('hex'))); + }); + +const verifyArchiveChecksum = async (filePath, name, checksumsText) => { + const expected = checksumForArchive(checksumsText, name); + if (!expected) { + throw new FatalInstallError(`checksum entry not found for ${name}`); + } + + const actual = await sha256File(filePath); + if (actual !== expected) { + unlinkIfExists(filePath); + throw new FatalInstallError( + `checksum mismatch for ${name}: expected ${expected}, got ${actual}`, + ); + } +}; + +const install = async () => { try { console.log(`optiqor: downloading binary for ${key}...`); const res = await get(url); await pipelineP(res, fs.createWriteStream(tarballPath)); + const checksums = await responseText(await get(checksumsUrl)); + await verifyArchiveChecksum(tarballPath, archiveName, checksums); // tar -xzf using system tar (avoids adding tar npm dep). execFileSync('tar', ['-xzf', tarballPath, '-C', vendorDir], { stdio: 'inherit' }); - fs.unlinkSync(tarballPath); + unlinkIfExists(tarballPath); console.log('optiqor: ready. Run `optiqor --version` to verify.'); } catch (err) { + unlinkIfExists(tarballPath); console.error('optiqor: failed to install binary:', err.message); + if (err instanceof FatalInstallError) { + console.error('optiqor: refusing to use an unverified release archive.'); + process.exit(1); + } console.error('optiqor: this is non-fatal — build from source if needed:'); console.error(' go install github.com/optiqor/optiqor-cli/cmd/optiqor@latest'); // Exit 0 so npm install does not abort entirely. process.exit(0); } -})(); +}; + +if (require.main === module) { + install(); +} + +module.exports = { + checksumForArchive, + sha256File, + verifyArchiveChecksum, + FatalInstallError, +}; diff --git a/npm/postinstall.test.js b/npm/postinstall.test.js new file mode 100644 index 0000000..b572246 --- /dev/null +++ b/npm/postinstall.test.js @@ -0,0 +1,85 @@ +const assert = require('assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); + +const { + FatalInstallError, + checksumForArchive, + sha256File, + verifyArchiveChecksum, +} = require('./postinstall'); + +const withTempFile = (contents) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'optiqor-postinstall-')); + const filePath = path.join(dir, 'archive.tar.gz'); + fs.writeFileSync(filePath, contents); + return { dir, filePath }; +}; + +test('checksumForArchive returns the SHA-256 for the exact archive name', () => { + const checksums = [ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa optiqor_1.2.3_linux_amd64.tar.gz', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb optiqor_1.2.3_darwin_arm64.tar.gz', + ].join('\n'); + + assert.equal( + checksumForArchive(checksums, 'optiqor_1.2.3_darwin_arm64.tar.gz'), + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ); +}); + +test('checksumForArchive supports star-prefixed checksum filenames', () => { + const checksums = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc *optiqor_1.2.3_linux_arm64.tar.gz'; + + assert.equal( + checksumForArchive(checksums, 'optiqor_1.2.3_linux_arm64.tar.gz'), + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + ); +}); + +test('verifyArchiveChecksum accepts a matching archive hash', async () => { + const { dir, filePath } = withTempFile('trusted archive'); + try { + const hash = await sha256File(filePath); + await verifyArchiveChecksum(filePath, 'archive.tar.gz', `${hash} archive.tar.gz`); + assert.equal(fs.existsSync(filePath), true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('verifyArchiveChecksum deletes a mismatched archive and fails loudly', async () => { + const { dir, filePath } = withTempFile('tampered archive'); + try { + await assert.rejects( + verifyArchiveChecksum( + filePath, + 'archive.tar.gz', + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd archive.tar.gz', + ), + FatalInstallError, + ); + assert.equal(fs.existsSync(filePath), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('verifyArchiveChecksum fails loudly when the archive is missing from checksums.txt', async () => { + const { dir, filePath } = withTempFile('trusted archive'); + try { + await assert.rejects( + verifyArchiveChecksum( + filePath, + 'archive.tar.gz', + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee other.tar.gz', + ), + FatalInstallError, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/package.json b/package.json index ecb38c2..a53ddb0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "scripts": { "postinstall": "node ./npm/postinstall.js", + "test:npm": "node --test npm/postinstall.test.js", "test": "node -e \"require('./npm/index.js')\"" }, "files": [