388 lines
13 KiB
JavaScript
388 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Download and verify the official Node.js distribution used by the desktop
|
|
* sidecar. The full archive is required because it is the authoritative source
|
|
* for the matching Node binary, Node license, and bundled npm runtime.
|
|
*
|
|
* Desktop packaging pins one exact supported Node.js release. Release and PR
|
|
* workflows install dependencies with the same version, and the extracted
|
|
* runtime proves it can load the installed native SQLite binding before any
|
|
* packaged runtime resource is replaced.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(__dirname, '..');
|
|
const resourcesDir = path.join(root, 'app', 'src-tauri', 'resources');
|
|
const cacheDir = path.join(__dirname, '.cache');
|
|
const stagedRuntimeDir = path.join(
|
|
resourcesDir,
|
|
'node_modules',
|
|
'waggle-node-runtime',
|
|
);
|
|
const SAFE_NPM_BRACE_EXPANSION_VERSION = '2.1.4';
|
|
const safeNpmBraceExpansionSource = path.join(
|
|
root,
|
|
'node_modules',
|
|
'archiver-utils',
|
|
'node_modules',
|
|
'brace-expansion',
|
|
);
|
|
const SAFE_NPM_IP_ADDRESS_VERSION = '10.4.0';
|
|
const safeNpmIpAddressSource = path.join(root, 'node_modules', 'ip-address');
|
|
|
|
const DESKTOP_NODE_VERSION = '22.23.2';
|
|
const NODE_VERSION = DESKTOP_NODE_VERSION;
|
|
if (!/^\d+\.\d+\.\d+$/.test(NODE_VERSION)) {
|
|
console.error(`[bundle-node] FATAL - invalid Node.js version: ${NODE_VERSION}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const platform = process.platform;
|
|
const arch = process.env.TARGET_ARCH || process.arch;
|
|
if (arch === 'universal') {
|
|
console.error(
|
|
'[bundle-node] FATAL - TARGET_ARCH=universal is not supported.\n'
|
|
+ ' Node.js ships per-arch binaries. Build arm64 and x64 separately.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (!['x64', 'arm64'].includes(arch)) {
|
|
console.error(`[bundle-node] FATAL - unsupported target architecture: ${arch}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const archivePlatform = platform === 'win32'
|
|
? 'win'
|
|
: platform === 'darwin'
|
|
? 'darwin'
|
|
: platform === 'linux'
|
|
? 'linux'
|
|
: null;
|
|
if (!archivePlatform) {
|
|
console.error(`[bundle-node] FATAL - unsupported platform: ${platform}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const archiveExtension = platform === 'win32' ? 'zip' : 'tar.gz';
|
|
const distributionName = `node-v${NODE_VERSION}-${archivePlatform}-${arch}`;
|
|
const archiveName = `${distributionName}.${archiveExtension}`;
|
|
const distributionUrl = `https://nodejs.org/dist/v${NODE_VERSION}`;
|
|
const archivePath = path.join(cacheDir, archiveName);
|
|
const shasumsPath = path.join(cacheDir, `node-v${NODE_VERSION}-SHASUMS256.txt`);
|
|
const extractDir = path.join(cacheDir, `${distributionName}-verified`);
|
|
const extractedRoot = path.join(extractDir, distributionName);
|
|
const nodeSource = platform === 'win32'
|
|
? path.join(extractedRoot, 'node.exe')
|
|
: path.join(extractedRoot, 'bin', 'node');
|
|
const npmSource = path.join(
|
|
extractedRoot,
|
|
...(platform === 'win32'
|
|
? ['node_modules', 'npm']
|
|
: ['lib', 'node_modules', 'npm']),
|
|
);
|
|
const nodeLicenseSource = path.join(extractedRoot, 'LICENSE');
|
|
const destBinary = path.join(resourcesDir, platform === 'win32' ? 'node.exe' : 'node');
|
|
|
|
function fail(message) {
|
|
console.error(`[bundle-node] FATAL - ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function assertNativeRuntimeCompatible() {
|
|
const betterSqlitePath = path.join(root, 'node_modules', 'better-sqlite3');
|
|
const probe = `
|
|
const path = require('node:path');
|
|
const Database = require(path.join(process.argv[1], 'node_modules', 'better-sqlite3'));
|
|
const database = new Database(':memory:');
|
|
const row = database.prepare('SELECT 1 AS ok').get();
|
|
database.close();
|
|
if (row?.ok !== 1) throw new Error('SQLite query probe returned an invalid result');
|
|
process.stdout.write(JSON.stringify({
|
|
version: process.versions.node,
|
|
abi: process.versions.modules,
|
|
arch: process.arch,
|
|
}));
|
|
`;
|
|
|
|
try {
|
|
const output = execFileSync(nodeSource, ['-e', probe, root], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
const runtime = JSON.parse(output);
|
|
if (runtime.version !== NODE_VERSION || runtime.arch !== arch) {
|
|
fail(
|
|
`native ABI compatibility probe used Node.js v${runtime.version ?? 'unknown'} `
|
|
+ `(${runtime.arch ?? 'unknown'}, ABI ${runtime.abi ?? 'unknown'}); expected `
|
|
+ `v${NODE_VERSION} (${arch})`,
|
|
);
|
|
}
|
|
console.log(
|
|
`[bundle-node] Native ABI probe passed with Node.js v${runtime.version} `
|
|
+ `(${runtime.arch}, ABI ${runtime.abi})`,
|
|
);
|
|
} catch (error) {
|
|
const detail = String(error?.stderr ?? error?.message ?? error).trim().slice(0, 4_000);
|
|
fail(
|
|
`native ABI compatibility probe failed for Node.js v${NODE_VERSION} (${arch}) `
|
|
+ `against ${betterSqlitePath}. Reinstall dependencies with Node.js `
|
|
+ `v${NODE_VERSION} (npm ci) before packaging. ${detail}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function download(url, destination) {
|
|
console.log(`[bundle-node] Downloading ${url}`);
|
|
const response = await fetch(url);
|
|
if (!response.ok) fail(`download failed: HTTP ${response.status} (${url})`);
|
|
const temporary = `${destination}.${process.pid}.tmp`;
|
|
fs.writeFileSync(temporary, Buffer.from(await response.arrayBuffer()));
|
|
fs.renameSync(temporary, destination);
|
|
}
|
|
|
|
function sha256(file) {
|
|
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
}
|
|
|
|
function expectedArchiveHash(shasums) {
|
|
for (const line of shasums.split(/\r?\n/)) {
|
|
const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim());
|
|
if (match?.[2] === archiveName) return match[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractedRuntimeComplete() {
|
|
return [
|
|
nodeSource,
|
|
nodeLicenseSource,
|
|
path.join(npmSource, 'LICENSE'),
|
|
path.join(npmSource, 'bin', 'npm-cli.js'),
|
|
path.join(npmSource, 'bin', 'npx-cli.js'),
|
|
].every((file) => fs.existsSync(file) && fs.lstatSync(file).isFile());
|
|
}
|
|
|
|
function directorySizeBytes(dir) {
|
|
let bytes = 0;
|
|
const stack = [dir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) stack.push(full);
|
|
else if (entry.isFile()) bytes += fs.statSync(full).size;
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function writeWrappers() {
|
|
const binDir = path.join(stagedRuntimeDir, 'bin');
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
if (platform === 'win32') {
|
|
const wrapper = (cli) => [
|
|
'@ECHO OFF',
|
|
'SETLOCAL',
|
|
'SET "NODE_EXE=%~dp0\\..\\..\\..\\node.exe"',
|
|
`SET "NPM_CLI_JS=%~dp0\\..\\node_modules\\npm\\bin\\${cli}-cli.js"`,
|
|
'"%NODE_EXE%" "%NPM_CLI_JS%" %*',
|
|
'EXIT /B %ERRORLEVEL%',
|
|
'',
|
|
].join('\r\n');
|
|
fs.writeFileSync(path.join(binDir, 'npm.cmd'), wrapper('npm'), 'utf8');
|
|
fs.writeFileSync(path.join(binDir, 'npx.cmd'), wrapper('npx'), 'utf8');
|
|
return;
|
|
}
|
|
|
|
const wrapper = (cli) => [
|
|
'#!/bin/sh',
|
|
'SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
`exec "$SCRIPT_DIR/../../../node" "$SCRIPT_DIR/../node_modules/npm/bin/${cli}-cli.js" "$@"`,
|
|
'',
|
|
].join('\n');
|
|
for (const cli of ['npm', 'npx']) {
|
|
const wrapperPath = path.join(binDir, cli);
|
|
fs.writeFileSync(wrapperPath, wrapper(cli), 'utf8');
|
|
fs.chmodSync(wrapperPath, 0o755);
|
|
}
|
|
}
|
|
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
|
|
async function loadExpectedHash(refresh = false) {
|
|
if (refresh) fs.rmSync(shasumsPath, { force: true });
|
|
if (!fs.existsSync(shasumsPath)) {
|
|
await download(`${distributionUrl}/SHASUMS256.txt`, shasumsPath);
|
|
}
|
|
const expected = expectedArchiveHash(fs.readFileSync(shasumsPath, 'utf8'));
|
|
if (expected) return expected;
|
|
if (!refresh) return loadExpectedHash(true);
|
|
fail(`official SHASUMS256.txt has no entry for ${archiveName}`);
|
|
}
|
|
|
|
let expectedHash = await loadExpectedHash();
|
|
let refreshedShasums = false;
|
|
|
|
if (fs.existsSync(archivePath) && sha256(archivePath) !== expectedHash) {
|
|
expectedHash = await loadExpectedHash(true);
|
|
refreshedShasums = true;
|
|
if (sha256(archivePath) !== expectedHash) {
|
|
console.warn(`[bundle-node] Discarding checksum-mismatched cache: ${archiveName}`);
|
|
fs.rmSync(archivePath, { force: true });
|
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
if (!fs.existsSync(archivePath)) {
|
|
await download(`${distributionUrl}/${archiveName}`, archivePath);
|
|
}
|
|
let actualHash = sha256(archivePath);
|
|
if (actualHash !== expectedHash && !refreshedShasums) {
|
|
expectedHash = await loadExpectedHash(true);
|
|
refreshedShasums = true;
|
|
actualHash = sha256(archivePath);
|
|
}
|
|
if (actualHash !== expectedHash) {
|
|
fs.rmSync(archivePath, { force: true });
|
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
fail(`SHA-256 mismatch for ${archiveName}: expected ${expectedHash}, got ${actualHash}`);
|
|
}
|
|
console.log(`[bundle-node] Verified ${archiveName} against official SHASUMS256.txt`);
|
|
|
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
fs.mkdirSync(extractDir, { recursive: true });
|
|
execFileSync('tar', ['-xf', archivePath, '-C', extractDir], { stdio: 'inherit' });
|
|
if (!extractedRuntimeComplete()) {
|
|
fail(`verified archive is missing Node, npm, or required license files: ${archiveName}`);
|
|
}
|
|
|
|
assertNativeRuntimeCompatible();
|
|
|
|
fs.copyFileSync(nodeSource, destBinary);
|
|
if (platform !== 'win32') fs.chmodSync(destBinary, 0o755);
|
|
|
|
fs.rmSync(stagedRuntimeDir, { recursive: true, force: true });
|
|
fs.mkdirSync(path.join(stagedRuntimeDir, 'node_modules'), { recursive: true });
|
|
fs.copyFileSync(nodeLicenseSource, path.join(stagedRuntimeDir, 'NODE-LICENSE'));
|
|
fs.cpSync(npmSource, path.join(stagedRuntimeDir, 'node_modules', 'npm'), {
|
|
recursive: true,
|
|
dereference: true,
|
|
});
|
|
const safeNpmBraceExpansionManifest = path.join(
|
|
safeNpmBraceExpansionSource,
|
|
'package.json',
|
|
);
|
|
if (!fs.existsSync(safeNpmBraceExpansionManifest)) {
|
|
fail('lock-installed brace-expansion hardening source is missing');
|
|
}
|
|
const safeNpmBraceExpansion = JSON.parse(
|
|
fs.readFileSync(safeNpmBraceExpansionManifest, 'utf8'),
|
|
);
|
|
if (safeNpmBraceExpansion.version !== SAFE_NPM_BRACE_EXPANSION_VERSION) {
|
|
fail(
|
|
`lock-installed brace-expansion is ${safeNpmBraceExpansion.version}; `
|
|
+ `expected ${SAFE_NPM_BRACE_EXPANSION_VERSION}`,
|
|
);
|
|
}
|
|
const stagedNpmBraceExpansion = path.join(
|
|
stagedRuntimeDir,
|
|
'node_modules',
|
|
'npm',
|
|
'node_modules',
|
|
'brace-expansion',
|
|
);
|
|
fs.rmSync(stagedNpmBraceExpansion, { recursive: true, force: true });
|
|
fs.cpSync(safeNpmBraceExpansionSource, stagedNpmBraceExpansion, {
|
|
recursive: true,
|
|
dereference: true,
|
|
});
|
|
const stagedNpmBraceExpansionVersion = JSON.parse(
|
|
fs.readFileSync(path.join(stagedNpmBraceExpansion, 'package.json'), 'utf8'),
|
|
).version;
|
|
if (stagedNpmBraceExpansionVersion !== SAFE_NPM_BRACE_EXPANSION_VERSION) {
|
|
fail(
|
|
`staged npm brace-expansion is ${stagedNpmBraceExpansionVersion}; `
|
|
+ `expected ${SAFE_NPM_BRACE_EXPANSION_VERSION}`,
|
|
);
|
|
}
|
|
console.log(
|
|
`[bundle-node] Hardened bundled npm with brace-expansion `
|
|
+ `${SAFE_NPM_BRACE_EXPANSION_VERSION}`,
|
|
);
|
|
const safeNpmIpAddressManifest = path.join(safeNpmIpAddressSource, 'package.json');
|
|
if (!fs.existsSync(safeNpmIpAddressManifest)) {
|
|
fail('lock-installed ip-address hardening source is missing');
|
|
}
|
|
const safeNpmIpAddress = JSON.parse(
|
|
fs.readFileSync(safeNpmIpAddressManifest, 'utf8'),
|
|
);
|
|
if (safeNpmIpAddress.version !== SAFE_NPM_IP_ADDRESS_VERSION) {
|
|
fail(
|
|
`lock-installed ip-address is ${safeNpmIpAddress.version}; `
|
|
+ `expected ${SAFE_NPM_IP_ADDRESS_VERSION}`,
|
|
);
|
|
}
|
|
const stagedNpmIpAddress = path.join(
|
|
stagedRuntimeDir,
|
|
'node_modules',
|
|
'npm',
|
|
'node_modules',
|
|
'ip-address',
|
|
);
|
|
fs.rmSync(stagedNpmIpAddress, { recursive: true, force: true });
|
|
fs.cpSync(safeNpmIpAddressSource, stagedNpmIpAddress, {
|
|
recursive: true,
|
|
dereference: true,
|
|
});
|
|
const stagedNpmIpAddressVersion = JSON.parse(
|
|
fs.readFileSync(path.join(stagedNpmIpAddress, 'package.json'), 'utf8'),
|
|
).version;
|
|
if (stagedNpmIpAddressVersion !== SAFE_NPM_IP_ADDRESS_VERSION) {
|
|
fail(
|
|
`staged npm ip-address is ${stagedNpmIpAddressVersion}; `
|
|
+ `expected ${SAFE_NPM_IP_ADDRESS_VERSION}`,
|
|
);
|
|
}
|
|
console.log(
|
|
`[bundle-node] Hardened bundled npm with ip-address ${SAFE_NPM_IP_ADDRESS_VERSION}`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(stagedRuntimeDir, 'package.json'),
|
|
`${JSON.stringify({
|
|
name: 'waggle-node-runtime',
|
|
private: true,
|
|
version: NODE_VERSION,
|
|
description: 'Verified Node.js npm runtime staged for the Waggle desktop sidecar',
|
|
}, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
writeWrappers();
|
|
|
|
const npmManifest = JSON.parse(
|
|
fs.readFileSync(path.join(stagedRuntimeDir, 'node_modules', 'npm', 'package.json'), 'utf8'),
|
|
);
|
|
for (const cli of ['npm', 'npx']) {
|
|
const cliPath = path.join(stagedRuntimeDir, 'node_modules', 'npm', 'bin', `${cli}-cli.js`);
|
|
const version = execFileSync(destBinary, [cliPath, '--version'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
if (version !== npmManifest.version) {
|
|
fail(`${cli} preflight returned ${version}; expected npm ${npmManifest.version}`);
|
|
}
|
|
}
|
|
|
|
const nodeSize = (fs.statSync(destBinary).size / 1024 / 1024).toFixed(1);
|
|
const npmSize = (directorySizeBytes(stagedRuntimeDir) / 1024 / 1024).toFixed(1);
|
|
console.log(
|
|
`[bundle-node] Node.js v${NODE_VERSION} + npm v${npmManifest.version} `
|
|
+ `(${platform}-${arch}) -> resources (${nodeSize} MB node, ${npmSize} MB npm runtime)`,
|
|
);
|