diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a2a6d375..c5f8dd25d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/scripts/verify-better-sqlite3-prebuild.mjs b/scripts/verify-better-sqlite3-prebuild.mjs new file mode 100644 index 000000000..fd8f372a7 --- /dev/null +++ b/scripts/verify-better-sqlite3-prebuild.mjs @@ -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 `/` 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; +});