Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,80 @@ jobs:
RUN_STUDIO_E2E: '1'


# better-sqlite3 publishes 10 Node-22 (ABI 127) prebuild targets for the pinned 12.9.0.
# Eight of them — darwin-arm64/x64, linux-x64/arm64/arm, linuxmusl-x64/arm64/arm — were
# verified on the build machine by loading the PUBLISHED release asset and driving it
# through an FTS5 MATCH. The two win32 targets could not be: there was no Windows kernel
# there, and the previous probe confirmed only the binaries' PE shape rather than
# synthesise a pass. This job closes that gap on real Windows kernels, at the same
# standard — a `require()` that returns an object only proves a file resolved, so each
# run builds an FTS5 index and queries it.
#
# windows-11-arm is a GitHub-hosted arm64 runner, free and generally available for PUBLIC
# repositories since 2025-08-07 (it is not a self-hosted label and needs no setup). This
# repository is public. On a private fork the label does not resolve and this leg will not
# start — that is a visibility fact about the fork, not a workflow bug.
#
# Deliberately no `npm ci`, and no npm at all: the probe unpacks the JS wrapper straight
# from the registry tarball the lockfile resolves to, checked against the lockfile's
# integrity hash. No install lifecycle runs, so prebuild-install and node-gyp cannot
# supply a locally built binding and let the probe verify itself. That also keeps this job
# independent of the better-sqlite3 v13 problem parked in PR #337 (v13 dropped its
# `install` script, so npm supplies an implicit `node-gyp rebuild` and `npm ci` hard-fails
# on a Windows box with no Visual Studio). The pin stays 12.9.0 and the probe reads it
# from package-lock.json.
#
# No `continue-on-error` anywhere in this job. The whole point of the Q6 phase is deleting
# greens that report success regardless of what their steps did.
win32-prebuild:
name: better-sqlite3 prebuild loads (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- runner: windows-latest
target: win32-x64
wrong_arch: win32-arm64
- runner: windows-11-arm
target: win32-arm64
wrong_arch: win32-x64
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 22

- name: Published ${{ matrix.target }} prebuild loads + serves an FTS5 MATCH
shell: bash
run: node scripts/verify-better-sqlite3-prebuild.mjs --target ${{ matrix.target }}

# Five controls, each of which MUST be rejected. Without them a green above is
# unfalsifiable: a probe that cannot fail says nothing about the binding it loaded.
# `--expect-fail` inverts only the LOAD — the asset must still download and extract,
# so a control cannot "pass" by 404ing on a mistyped target.
- name: Negative control — wrong arch (${{ matrix.wrong_arch }})
shell: bash
run: node scripts/verify-better-sqlite3-prebuild.mjs --target ${{ matrix.wrong_arch }} --expect-fail

- name: Negative control — wrong platform (linux-x64 ELF)
shell: bash
run: node scripts/verify-better-sqlite3-prebuild.mjs --target linux-x64 --expect-fail

- name: Negative control — wrong libc (linuxmusl-x64)
shell: bash
run: node scripts/verify-better-sqlite3-prebuild.mjs --target linuxmusl-x64 --expect-fail

- name: Negative control — wrong ABI (Node 18, v115)
shell: bash
run: node scripts/verify-better-sqlite3-prebuild.mjs --target ${{ matrix.target }} --abi 115 --expect-fail

- name: Negative control — binding absent
shell: bash
run: node scripts/verify-better-sqlite3-prebuild.mjs --missing-binding --expect-fail

# Clean-machine smoke on every desktop OS: a fresh global install, a real
# `init`, then tool calls that LOAD and EXERCISE every dependency subsystem —
# native SQLite, browser engine, ML reranker, semantic embeddings, and (when a
Expand Down
228 changes: 228 additions & 0 deletions scripts/verify-better-sqlite3-prebuild.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env node
/**
* Verify that a PUBLISHED better-sqlite3 prebuild asset genuinely loads, and that the
* native surface it exposes actually works.
*
* Loading is not the bar. A `require()` that returns an object only proves a file
* resolved; it does not prove the extension's SQLite build carries FTS5, which is the
* one compile-time option wigolo's cache cannot run without. So every positive run
* builds an FTS5 index and drives a `MATCH` through it, and asserts a non-matching
* query returns nothing — a MATCH that returns every row is not a MATCH.
*
* The asset is downloaded from the release, not taken from `node_modules`: the point is
* to verify the artifact users receive, on the platform they receive it for. The JS
* wrapper is unpacked straight from the registry tarball, so no install lifecycle runs and
* nothing can quietly compile a fresh binding and verify itself.
*
* Usage:
* node scripts/verify-better-sqlite3-prebuild.mjs # host target
* node scripts/verify-better-sqlite3-prebuild.mjs --target win32-arm64
* node scripts/verify-better-sqlite3-prebuild.mjs --target linux-x64 --expect-fail
* node scripts/verify-better-sqlite3-prebuild.mjs --abi 115 --expect-fail
* node scripts/verify-better-sqlite3-prebuild.mjs --missing-binding --expect-fail
*
* `--expect-fail` inverts the exit code, but only over the LOAD. Download and extract
* must still succeed: a control that "passes" because the asset 404'd would prove the
* URL was wrong, not that the binding was rejected.
*/

import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

function parseArgs(argv) {
const opts = { target: null, abi: null, expectFail: false, missingBinding: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--expect-fail') opts.expectFail = true;
else if (arg === '--missing-binding') opts.missingBinding = true;
else if (arg === '--target') opts.target = argv[++i];
else if (arg === '--abi') opts.abi = argv[++i];
else if (arg.startsWith('--target=')) opts.target = arg.slice('--target='.length);
else if (arg.startsWith('--abi=')) opts.abi = arg.slice('--abi='.length);
else throw new Error(`unknown argument: ${arg}`);
}
return opts;
}

/** The pin is the lockfile's, never a literal here — a version bump must not silently
* leave this probe verifying the previous release. */
function lockedPackage() {
const lockPath = path.join(REPO_ROOT, 'package-lock.json');
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const entry = lock.packages?.['node_modules/better-sqlite3'];
if (!entry?.version || !entry.resolved || !entry.integrity) {
throw new Error(`no complete "node_modules/better-sqlite3" entry in ${lockPath}`);
}
return { version: entry.version, resolved: entry.resolved, integrity: entry.integrity };
}

function assetName(version, abi, target) {
return `better-sqlite3-v${version}-node-v${abi}-${target}.tar.gz`;
}

/**
* Extract `<dir>/<name>` in place.
*
* The tarball is named RELATIVELY with `cwd`, never as an absolute path with `-C`. Under
* `shell: bash` on a Windows runner, PATH resolves `tar` to GNU tar from Git for Windows,
* which reads `C:\...` as a `host:path` remote spec and fails with
* "Cannot connect to C: resolve failed". A relative name has no colon, so GNU tar and the
* bsdtar shipped in System32 both behave.
*/
function extract(dir, name) {
execFileSync('tar', ['-xzf', name], { stdio: 'inherit', cwd: dir });
}

async function download(url, dest) {
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status} ${res.statusText}`);
const bytes = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(dest, bytes);
return bytes.length;
}

/**
* The JS wrapper, unpacked straight from the registry tarball the lockfile resolves to and
* checked against the lockfile's integrity hash.
*
* `npm install` is deliberately not used. It would need `npm.cmd` on Windows, which Node
* refuses to spawn without `shell: true` (the CVE-2024-27980 hardening) — the same trap
* that made `tests/e2e/init-command.e2e.test.ts` spawn a child that never started. Beyond
* dodging that, unpacking directly means no install lifecycle exists at all, so neither
* prebuild-install nor node-gyp can supply a binding and let this probe verify itself. The
* wrapper's only non-relative dependency is `bindings`, which `lib/database.js` requires
* lazily and only when `nativeBinding` is null — this probe always passes it a path.
*/
async function fetchWrapper(pkg, dir) {
const wrapperDir = path.join(dir, 'package');
if (fs.existsSync(path.join(wrapperDir, 'lib', 'database.js'))) return wrapperDir;
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
const tarball = path.join(dir, 'wrapper.tgz');
await download(pkg.resolved, tarball);

const [algo, expected] = pkg.integrity.split('-');
const actual = createHash(algo).update(fs.readFileSync(tarball)).digest('base64');
if (actual !== expected) {
throw new Error(`${pkg.resolved} ${algo} is ${algo}-${actual}, lockfile says ${pkg.integrity}`);
}

extract(dir, 'wrapper.tgz');
if (!fs.existsSync(path.join(wrapperDir, 'lib', 'database.js'))) {
throw new Error(`registry tarball extracted without package/lib/database.js in ${dir}`);
}
return wrapperDir;
}

function driveFts5(Database, bindingPath) {
const db = new Database(':memory:', { nativeBinding: bindingPath });
try {
const sqliteVersion = db.prepare('SELECT sqlite_version() AS v').get().v;
db.exec('CREATE VIRTUAL TABLE docs USING fts5(title, body)');
const insert = db.prepare('INSERT INTO docs (title, body) VALUES (?, ?)');
insert.run('prebuild-under-test', 'the published asset carries a working fts5 module');
insert.run('unrelated-row', 'nothing here should answer the query below');
insert.run('second-unrelated-row', 'nor should this one');

const hits = db
.prepare('SELECT title FROM docs WHERE docs MATCH ? ORDER BY rank')
.all('fts5');
const titles = hits.map((r) => r.title);
if (titles.length !== 1 || titles[0] !== 'prebuild-under-test') {
throw new Error(`MATCH returned ${JSON.stringify(titles)}, expected ["prebuild-under-test"]`);
}

// A MATCH that answers everything is indistinguishable from a table scan.
const misses = db.prepare('SELECT title FROM docs WHERE docs MATCH ?').all('nonexistentterm');
if (misses.length !== 0) {
throw new Error(`non-matching MATCH returned ${misses.length} rows, expected 0`);
}
return { sqliteVersion, titles };
} finally {
db.close();
}
}

async function main() {
const opts = parseArgs(process.argv.slice(2));
const pkg = lockedPackage();
const version = pkg.version;
const abi = opts.abi ?? process.versions.modules;
const target = opts.target ?? `${process.platform}-${process.arch}`;
const hostTarget = `${process.platform}-${process.arch}`;

const work = path.join(os.tmpdir(), `bs3-prebuild-probe-${abi}-${target}`);
fs.rmSync(work, { recursive: true, force: true });
fs.mkdirSync(work, { recursive: true });

const wrapperDir = await fetchWrapper(pkg, path.join(os.tmpdir(), 'bs3-prebuild-wrapper'));

console.log(`better-sqlite3 version : ${version} (from package-lock.json)`);
console.log(`host : node ${process.version} / ${hostTarget} / ABI ${process.versions.modules}`);
console.log(`target under test : ${target} / ABI ${abi}`);
console.log(`wrapper (no install lifecycle): ${wrapperDir}`);

let bindingPath;
if (opts.missingBinding) {
bindingPath = path.join(work, 'build', 'Release', 'better_sqlite3.node');
console.log(`binding : ${bindingPath} (deliberately absent)`);
if (fs.existsSync(bindingPath)) throw new Error('the "missing" binding exists — control is void');
} else {
const name = assetName(version, abi, target);
const url = `https://github.com/WiseLibs/better-sqlite3/releases/download/v${version}/${name}`;
const tarball = path.join(work, name);
const bytes = await download(url, tarball);
extract(work, name);
bindingPath = path.join(work, 'build', 'Release', 'better_sqlite3.node');
if (!fs.existsSync(bindingPath)) {
throw new Error(`${name} extracted without build/Release/better_sqlite3.node`);
}
const sha = createHash('sha256').update(fs.readFileSync(bindingPath)).digest('hex');
console.log(`asset : ${name} (${bytes} bytes)`);
console.log(`asset url : ${url}`);
console.log(`binding : ${bindingPath}`);
console.log(`binding sha256 : ${sha}`);
}

const require = createRequire(import.meta.url);
const Database = require(wrapperDir);

let result = null;
let failure = null;
try {
result = driveFts5(Database, bindingPath);
} catch (err) {
failure = err;
}

if (opts.expectFail) {
if (failure) {
console.log(`\nNEGATIVE CONTROL HELD — ${target} / ABI ${abi} was REJECTED on ${hostTarget}`);
console.log(` rejection: ${String(failure.message).split('\n')[0]}`);
return;
}
console.error(
`\nNEGATIVE CONTROL FAILED — ${target} / ABI ${abi} LOADED and ran FTS5 on ${hostTarget}.` +
' A control that cannot fail proves nothing about the positive runs beside it.'
);
process.exitCode = 1;
return;
}

if (failure) throw failure;
console.log(`sqlite : ${result.sqliteVersion}`);
console.log(`fts5 MATCH : ${JSON.stringify(result.titles)}`);
console.log(`\nVERIFIED — ${target} prebuild loaded and served an FTS5 MATCH on ${hostTarget}`);
}

main().catch((err) => {
console.error(`\nFAILED — ${err?.stack ?? err}`);
process.exitCode = 1;
});
Loading