From af2f4d8ba747a930a970550b133e44fc32e86306 Mon Sep 17 00:00:00 2001 From: Anton Pepperok <101743951+PerchAnton@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:31:38 +0200 Subject: [PATCH] Clean package builds and keep the demo alive on malformed URLs --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 3 ++ CONTRIBUTING.md | 2 +- README.md | 5 ++- docs/evidence.md | 6 ++- package.json | 3 +- scripts/build.mjs | 12 +++++ scripts/check-package.mjs | 9 ++++ scripts/demo-server.mjs | 23 ++++++++++ scripts/serve.mjs | 18 +------- tests/boundaries.test.mjs | 91 ++++++++++++++++++++++++++++++++++++++ tests/demo/server.test.mjs | 48 ++++++++++++++++++++ 12 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 scripts/build.mjs create mode 100644 scripts/demo-server.mjs create mode 100644 tests/boundaries.test.mjs create mode 100644 tests/demo/server.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf6f558..cdd0883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm check - run: pnpm example + - run: pnpm test:demo - run: pnpm test:package browsers: name: Chromium / Firefox / WebKit diff --git a/CHANGELOG.md b/CHANGELOG.md index ee55aac..c42f635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,5 +12,8 @@ - Extend finite mutation tests with insertions, deletions, adjacent swaps and 3,000 distinct multiple-substitution samples; exercise Unicode rejection in three browser engines. - Verify an offline installation of the packed archive, including public exports, TypeScript brands, documentation links and measured runtime hashes. - Include the README graphic and supporting documents in the archive, and default future npm publications to the `alpha` tag. +- Clean previous build output before compiling, so deleted modules cannot leak into the archive; verify this with a synthetic stale module during package checks. +- Return HTTP 400 for malformed demo URLs without stopping the local server; test the asset allowlist and subsequent requests. +- Check all 912 supported prefix/payload length combinations and 1,766 count/budget pairs around exact floating-point cutoffs, including unattainable budgets. This version number identifies the source/package candidate. Publication to npm or a GitHub release is not implied. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 126f180..c88967d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Start with a concrete reproduction: what reference was entered, expected behavior, actual behavior, runtime and version. Use synthetic public references; do not post private records or access tokens. -Run `pnpm install --frozen-lockfile --ignore-scripts` and `pnpm check`. Run `pnpm test:package` to verify the installed archive's API, types and documents. Changes to parsing or the form also need `pnpm test:browser` after installing Playwright's browser engines. Keep the format specification and examples consistent. Add a regression that demonstrates a real failure, and retain the standard vectors and independent oracle. +Run `pnpm install --frozen-lockfile --ignore-scripts` and `pnpm check`. Run `pnpm test:package` to verify the installed archive's API, types and documents. Builds replace `dist/`; do not keep hand-written files there. Changes to the demo server need `pnpm test:demo` (uses an ephemeral loopback port). Changes to parsing or the form also need `pnpm test:browser` after installing Playwright's browser engines. Keep the format specification and examples consistent. Add a regression that demonstrates a real failure, and retain the standard vectors and independent oracle. Use `refmint/` for branches. Keep the runtime dependency-free and the API focused. New formats, frameworks or language ports need a demonstrated consumer need. Evidence must describe the environment and measurement method; no unsupported performance or adoption claims. diff --git a/README.md b/README.md index e1675db..8e4d5c2 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ RefMint has six checksum characters of overhead and does not sort by time. It ma ```sh pnpm check # build, standard vectors, independent oracle, mutation + type tests pnpm example # actual lookup example +pnpm test:demo # malformed HTTP requests and the demo asset allowlist pnpm evidence # regenerate measured evidence and the SVG above pnpm test:package # offline install of the .tgz, API + types + documentation checks pnpm exec playwright install chromium firefox webkit @@ -103,7 +104,9 @@ pnpm pack --pack-destination artifacts CI runs the core checks on Node 22, 24 and 26, including Windows and macOS, plus the browser example on Chromium, Firefox and WebKit. The workflow badge reports its current result; the matrix is a test target until that run succeeds. See [SPEC.md](SPEC.md), [CHANGELOG.md](CHANGELOG.md), [security policy](SECURITY.md) and [contribution guide](CONTRIBUTING.md). -The package check installs into a separate consumer with an empty dependency store and network access disabled for installation. It checks the exported API, namespace types, shipped documentation links and runtime hashes against the recorded evidence. Future npm alpha publications default to the `alpha` tag; that setting does not publish a package. +The package check first plants an obsolete build file and verifies that a clean build removes it. It then installs into a separate consumer with an empty dependency store and network access disabled for installation, checking the archive contents, exported API, namespace types, shipped documentation links and runtime hashes against the recorded evidence. Future npm alpha publications default to the `alpha` tag; that setting does not publish a package. + +Boundary tests cover all **912** supported prefix/payload length pairs and **1,766** count/budget pairs near floating-point cutoffs. The budget oracle compares exact integer ratios, including subnormal targets and impossible budgets. These checks validate the stated rules; they do not measure human error rates. ## License diff --git a/docs/evidence.md b/docs/evidence.md index ca65e3f..7946f8e 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -6,11 +6,15 @@ Run `pnpm check && pnpm evidence` from a clean checkout after installing the loc Core tests check all 7 valid and 14 invalid generic Bech32m vectors from BIP-350. They compare generated output with a separate implementation (`bech32` 2.0.0) for all payload lengths from 8 through 64 and four prefixes. The planner is checked against exact BigInt pair counts at representative budgets. Type checks demonstrate namespace separation. +The boundary suite extends public-API round trips to all 16 prefix lengths × 57 payload lengths = **912 combinations**, including prefixes containing `1`, display normalization, and the raw-input limits of 128/129 characters. Its independent numeric oracle converts each budget's IEEE-754 value into an exact integer ratio. It checks **1,766 distinct count/budget pairs**, including immediately neighboring floating-point values around length transitions, counts up to the largest safe integer, subnormal targets and unattainable budgets. Selected non-string inputs, including an object with throwing access hooks, must be rejected without inspecting or coercing them. + The evidence script enumerates 31 alternate symbols at every payload and checksum position for four known references. Their payload lengths are 8, 14, 26 and 64, giving `(14 + 20 + 32 + 70) × 31 = 4,216` substitutions. All must be rejected or the script fails. The core suite additionally checks 3,000 distinct deterministic samples with 2–4 substituted positions (1,000 per count), all single-symbol insertions and deletions in those four bodies, and adjacent swaps of unequal symbols. This is finite test evidence; it is not an exhaustive proof of the standard's error-detection properties. ASCII case folding and valid grouping are deliberately accepted and are not counted as detected errors. Non-ASCII input is rejected before case folding. Browser tests exercise the actual form in Chromium, Firefox and WebKit: printed uppercase reference, one typo, unknown valid reference, replacing the sample ticket, a narrow viewport and rejection of a Unicode lookalike. There are three tests per engine. Hosted results are available in the CI workflow. Local results and hosted results must be identified separately. -`pnpm test:package` packs the distributable and installs it offline into a separate consumer with an empty store. It checks runtime imports, TypeScript namespace brands, blocked private-module imports, shipped Markdown links and SHA-256 hashes of the measured JavaScript. The package includes the evidence image and documents; builds force LF line endings so the same source produces the same runtime hashes on the tested operating systems. +`pnpm test:package` seeds a synthetic obsolete module in `dist/`, verifies that the pack build removes it, and checks the archive's exact build-file inventory. It installs the archive offline into a separate consumer with an empty store and checks runtime imports, TypeScript namespace brands, blocked private-module imports, shipped Markdown links and SHA-256 hashes of the measured JavaScript. The package includes the evidence image and documents; builds force LF line endings so the same source produces the same runtime hashes on the tested operating systems. + +`pnpm test:demo` uses an ephemeral loopback port. Malformed request targets must return HTTP 400 and leave the next normal request working; only the three demo assets are served, with the expected MIME types and no caching. These tests cover the local development server, which is not a production hosting service. ## Size and speed diff --git a/package.json b/package.json index 0bacdd1..936cce1 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,11 @@ "THIRD_PARTY_NOTICES.md" ], "scripts": { - "build": "tsc -p tsconfig.json", + "build": "node scripts/build.mjs", "test": "node --test tests/*.test.mjs", "test:types": "tsc -p tests/tsconfig.json", "test:package": "node scripts/check-package.mjs", + "test:demo": "node --test tests/demo/*.test.mjs", "check": "pnpm build && pnpm test && pnpm test:types", "example": "node examples/ticket-lookup.mjs", "evidence": "node scripts/evidence.mjs", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..f0b3286 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,12 @@ +import { rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = new URL('..', import.meta.url); +// TypeScript does not remove outputs of deleted or renamed source files. +await rm(new URL('dist/', root), { recursive: true, force: true }); +const result = spawnSync(process.execPath, [ + fileURLToPath(new URL('node_modules/typescript/bin/tsc', root)), '-p', 'tsconfig.json' +], { cwd: fileURLToPath(root), stdio: 'inherit' }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 4373c8b..41d89a3 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -11,6 +11,7 @@ const root = fileURLToPath(new URL('..', import.meta.url)); const manager = process.env.npm_execpath; assert.ok(manager, 'Run this check with pnpm test:package'); const temp = await mkdtemp(join(tmpdir(), 'refmint-package-')); +let stale; function run(script, args, cwd) { const result = spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8', env: process.env, timeout: 120000 }); if (result.status !== 0) throw new Error(`${script} failed:\n${result.stdout}\n${result.stderr}\n${result.error ?? ''}`); @@ -19,7 +20,12 @@ try { const source = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); const packs = join(temp, 'packs'); await mkdir(packs); + // A previous build may have emitted a module that has since been deleted. + await mkdir(join(root, 'dist'), { recursive: true }); + stale = await mkdtemp(join(root, 'dist', 'refmint-stale-')); + await writeFile(join(stale, 'removed-module.js'), '// Synthetic old build output.\n'); run(manager, ['pack', '--pack-destination', packs], root); + await assert.rejects(readFile(join(stale, 'removed-module.js')), { code: 'ENOENT' }); const archives = (await readdir(packs)).filter(file => file.endsWith('.tgz')); assert.equal(archives.length, 1); const consumer = join(temp, 'consumer'); @@ -36,6 +42,8 @@ try { assert.equal(packaged.publishConfig.tag, 'alpha'); assert.deepEqual(packaged.dependencies ?? {}, {}); const files = await readdir(installed, { recursive: true }); + assert.deepEqual((await readdir(join(installed, 'dist'))).sort(), + ['bech32m.d.ts', 'bech32m.js', 'index.d.ts', 'index.js'], 'Unexpected build output in the archive'); assert.ok(!files.some(file => /(^|[/\\])(src|tests|scripts|\.github|node_modules)([/\\]|$)/.test(file))); for (const name of ['dist/index.js', 'dist/index.d.ts', 'dist/bech32m.js', 'SPEC.md', 'LICENSE-MIT', 'LICENSE-APACHE', 'THIRD_PARTY_NOTICES.md']) { assert.ok((await readFile(join(installed, name))).length > 0, name); @@ -83,5 +91,6 @@ if (result.ok) docs.format(result.id); run(join(root, 'node_modules/typescript/bin/tsc'), ['--noEmit', '--strict', '--target', 'ES2022', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', 'types.mts'], consumer); console.log(`PASS: ${source.name}@${source.version} archive installs offline; ESM, types, documentation and licenses are intact.`); } finally { + if (stale) await rm(stale, { recursive: true, force: true }); await rm(temp, { recursive: true, force: true }); } diff --git a/scripts/demo-server.mjs b/scripts/demo-server.mjs new file mode 100644 index 0000000..77dcce4 --- /dev/null +++ b/scripts/demo-server.mjs @@ -0,0 +1,23 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; + +const files = new Map([ + ['/', ['../examples/browser/index.html', 'text/html; charset=utf-8']], + ['/dist/index.js', ['../dist/index.js', 'text/javascript; charset=utf-8']], + ['/dist/bech32m.js', ['../dist/bech32m.js', 'text/javascript; charset=utf-8']] +]); + +export function createDemoServer() { + return createServer(async (req, res) => { + let pathname; + try { pathname = new URL(req.url, 'http://localhost').pathname; } + catch { res.writeHead(400).end('Bad request.'); return; } + const entry = files.get(pathname); + if (!entry || req.method !== 'GET') { res.writeHead(404).end(); return; } + try { + const data = await readFile(new URL(entry[0], import.meta.url)); + res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-store' }); + res.end(data); + } catch { res.writeHead(500).end('Build the library with pnpm build first.'); } + }); +} diff --git a/scripts/serve.mjs b/scripts/serve.mjs index a5dfb9d..cac76a6 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -1,18 +1,4 @@ -import { createServer } from 'node:http'; -import { readFile } from 'node:fs/promises'; +import { createDemoServer } from './demo-server.mjs'; -const files = new Map([ - ['/', ['../examples/browser/index.html', 'text/html; charset=utf-8']], - ['/dist/index.js', ['../dist/index.js', 'text/javascript; charset=utf-8']], - ['/dist/bech32m.js', ['../dist/bech32m.js', 'text/javascript; charset=utf-8']] -]); -const server = createServer(async (req, res) => { - const entry = files.get(new URL(req.url, 'http://localhost').pathname); - if (!entry || req.method !== 'GET') { res.writeHead(404).end(); return; } - try { - const data = await readFile(new URL(entry[0], import.meta.url)); - res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-store' }); - res.end(data); - } catch { res.writeHead(500).end('Build the library with pnpm build first.'); } -}); +const server = createDemoServer(); server.listen(4173, '127.0.0.1', () => console.log('RefMint demo: http://127.0.0.1:4173')); diff --git a/tests/boundaries.test.mjs b/tests/boundaries.test.mjs new file mode 100644 index 0000000..d9482f0 --- /dev/null +++ b/tests/boundaries.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bech32m } from 'bech32'; +import { createReferences, planReferences } from '../dist/index.js'; + +// Independent oracle: compare exact integer ratios, including the precise IEEE +// value of the user's budget, rather than rounding a bound as the library does. +function ratio(value) { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + const bits = view.getBigUint64(0); + const exponent = Number((bits >> 52n) & 0x7ffn); + const significand = (bits & ((1n << 52n) - 1n)) | (exponent ? 1n << 52n : 0n); + const shift = exponent ? exponent - 1075 : -1074; + return shift >= 0 ? [significand << BigInt(shift), 1n] : [significand, 1n << BigInt(-shift)]; +} + +function neighbor(value, direction) { + if (value === 0 && direction < 0) return 0; + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + view.setBigUint64(0, view.getBigUint64(0) + BigInt(direction)); + return view.getFloat64(0); +} + +test('planner meets exact budgets immediately above and below length cutoffs', t => { + let checked = 0; + const spaces = Array.from({ length: 65 }, (_, length) => 1n << BigInt(5 * length)); + const counts = [1, 2, 3, 1000, 1_000_000, 2 ** 26, 2 ** 27 + 1, 2 ** 32 - 1, 2 ** 32, 2 ** 48 + 1, + Number.MAX_SAFE_INTEGER - 1, Number.MAX_SAFE_INTEGER]; + for (const count of counts) { + const pairs = BigInt(count) * BigInt(count - 1) / 2n; + const budgets = new Set([Number.MIN_VALUE, 2 ** -320, 1e-9, neighbor(1, -1)]); + for (let length = 8; length <= 64; length++) { + const cutoff = Number(pairs) / 32 ** length; + for (const p of [neighbor(cutoff, -1), cutoff, neighbor(cutoff, 1)]) { + if (p > 0 && p < 1) budgets.add(p); + } + } + for (const p of budgets) { + const [numerator, denominator] = ratio(p); + const exactLength = spaces.findIndex((space, length) => length >= 8 && pairs * denominator <= numerator * space); + const options = { prefix: 'budget', expectedCount: count, maxCollisionProbability: p }; + if (exactLength === -1) assert.throws(() => planReferences(options), RangeError); + else { + const plan = planReferences(options); + assert.equal(plan.randomLength, exactLength, `${count} references at budget ${p}`); + assert.ok(plan.collisionUpperBound <= p); + const [boundNumerator, boundDenominator] = ratio(plan.collisionUpperBound); + assert.ok(boundNumerator * spaces[exactLength] >= pairs * boundDenominator); + assert.ok(plan.collisionProbability >= 0 && plan.collisionProbability <= plan.collisionUpperBound); + } + checked++; + } + } + t.diagnostic(`${checked} distinct count/budget pairs checked with exact integer ratios`); +}); + +test('all 912 prefix/payload length combinations preserve canonical identity', () => { + for (let prefixLength = 1; prefixLength <= 16; prefixLength++) { + const prefix = ('a123456789abcdef').repeat(2).slice(0, prefixLength); + for (let length = 8; length <= 64; length++) { + const refs = createReferences({ prefix, randomLength: length }); + const id = bech32m.encode(prefix, Array.from({ length }, (_, i) => (i * 17 + length) % 32)); + assert.equal(id.length, refs.plan.canonicalLength); + assert.deepEqual(refs.parseCanonical(id), { ok: true, id, normalized: false }); + const printed = refs.format(id); + for (const input of [id.toUpperCase(), printed, printed.toUpperCase(), ' \t' + printed + '\r\n']) { + assert.deepEqual(refs.parse(input), { ok: true, id, normalized: true }); + assert.equal(refs.parseCanonical(input).error, 'non_canonical'); + } + // Check the actual input limit independently of the canonical length. + const padded = ' '.repeat(128 - printed.length) + printed; + assert.equal(refs.parse(padded).id, id); + assert.equal(refs.parse(padded + ' ').error, 'too_long'); + const generated = refs.generate(); + const decoded = bech32m.decode(generated); + assert.equal(decoded.prefix, prefix); + assert.equal(decoded.words.length, length); + } + } +}); + +test('parsing arbitrary values does not invoke user conversion hooks', () => { + const refs = createReferences({ prefix: 'ticket' }); + const hostile = new Proxy({}, { get() { throw new Error('Do not inspect this object'); } }); + for (const input of [undefined, null, false, 0, NaN, Infinity, 1n, Symbol('input'), [], {}, new String('ticket'), hostile]) { + assert.deepEqual(refs.parse(input), { ok: false, error: 'type' }); + assert.deepEqual(refs.parseCanonical(input), { ok: false, error: 'type' }); + } +}); diff --git a/tests/demo/server.test.mjs b/tests/demo/server.test.mjs new file mode 100644 index 0000000..262536c --- /dev/null +++ b/tests/demo/server.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { once } from 'node:events'; +import test, { before, after } from 'node:test'; +import { createDemoServer } from '../../scripts/demo-server.mjs'; + +const server = createDemoServer(); +before(async () => { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); +}); +after(() => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))); + +function get(path, method = 'GET') { + return new Promise((resolve, reject) => { + const req = request({ hostname: '127.0.0.1', port: server.address().port, path, method, agent: false }, res => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { body += chunk; }); + res.on('error', reject); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on('error', reject); + req.setTimeout(5000, () => req.destroy(new Error('Demo request timed out'))); + req.end(); + }); +} + +test('malformed URL returns 400 and the next request still succeeds', async () => { + for (const path of ['//[', 'http://[']) assert.equal((await get(path)).status, 400); + const page = await get('/'); + assert.equal(page.status, 200); + assert.match(page.body, /RefMint/); +}); + +test('only demo assets are served, with correct types and no caching', async () => { + for (const [path, type] of [['/?example=1', 'text/html'], ['/dist/index.js', 'text/javascript'], ['/dist/bech32m.js', 'text/javascript']]) { + const response = await get(path); + assert.equal(response.status, 200); + assert.ok(response.headers['content-type'].startsWith(type)); + assert.equal(response.headers['cache-control'], 'no-store'); + assert.ok(response.body.length > 0); + } + for (const path of ['/package.json', '/../package.json', '/%2e%2e/package.json', '/src/index.ts']) { + assert.equal((await get(path)).status, 404); + } + assert.equal((await get('/', 'POST')).status, 404); +});