From fed62ebb4866a90e45eec444030ed0193223724a Mon Sep 17 00:00:00 2001 From: Anton Pepperok <101743951+PerchAnton@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:58:28 +0200 Subject: [PATCH] Reject Unicode lookalikes and verify installed alpha packages --- .github/workflows/ci.yml | 17 +++++-- .gitignore | 1 + CHANGELOG.md | 5 ++ CONTRIBUTING.md | 4 +- README.md | 6 ++- SPEC.md | 4 +- docs/assets/evidence.svg | 2 +- docs/evidence.json | 44 +++++++++--------- docs/evidence.md | 6 ++- package.json | 6 +++ scripts/check-package.mjs | 87 +++++++++++++++++++++++++++++++++++ src/index.ts | 13 ++++-- tests/bech32m.test.mjs | 28 +++++++++-- tests/browser/lookup.spec.mjs | 9 ++++ tests/references.test.mjs | 29 +++++++++++- tsconfig.json | 1 + 16 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 scripts/check-package.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2bf6fa..bf6f558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,14 @@ on: pull_request: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: core: name: Node ${{ matrix.node }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -19,23 +23,26 @@ jobs: - { node: 24, os: windows-latest } - { node: 24, os: macos-latest } steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: ${{ matrix.node }} + package-manager-cache: false - run: npm install -g pnpm@11.25.0 - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm check - run: pnpm example - - run: pnpm pack --pack-destination artifacts + - run: pnpm test:package browsers: name: Chromium / Firefox / WebKit runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 24 + package-manager-cache: false - run: npm install -g pnpm@11.25.0 - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm build diff --git a/.gitignore b/.gitignore index e898de6..d614c67 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist/ *.tgz playwright-report/ test-results/ +artifacts/ .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 1427158..ee55aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,5 +7,10 @@ - Parse human input, require canonical storage input, and format groups of four. - Distinguish record namespaces in TypeScript and validate them at runtime. - Include a ticket lookup demo, standard vectors, independent-oracle tests and reproducible evidence. +- Reject Unicode lookalikes before case folding, including the Kelvin sign that previously normalized to ASCII `k`. +- Snapshot configuration once so dynamic getters cannot make the prefix disagree with its length plan. +- 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. 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 b07cce1..126f180 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,8 @@ 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`. Changes to 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. 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. -`pnpm evidence` regenerates recorded measurements and the README graphic. Do not replace another machine's measurements without labeling the new environment. Contributions to original code are under MIT OR Apache-2.0; preserve third-party notices. +After runtime changes, run `pnpm evidence` before `pnpm test:package`: the package check compares the installed JavaScript with the measured hashes. Builds use LF line endings on every platform. The evidence command regenerates recorded measurements and the README graphic and labels the new environment. Do not replace another machine's measurements without that label. Contributions to original code are under MIT OR Apache-2.0; preserve third-party notices. diff --git a/README.md b/README.md index 35ce0f4..e1675db 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RefMint is a small TypeScript library for public ticket, document and order references. It combines a random payload, a type prefix and a standard Bech32m checksum. A form can distinguish a mistyped code from a valid reference for a different record type, before querying a database. -**Alpha:** the format and API may change. This repository is usable from source; no npm publication is claimed. Node.js 22+ and browsers with Web Crypto are the targets. +**Alpha:** the format and API may change. This repository is usable from source; no npm publication is claimed. The package uses ES modules and includes TypeScript declarations. Node.js 22+ and browsers with Web Crypto are the targets. ![Measured checks and the reference lifecycle](docs/assets/evidence.svg) @@ -66,6 +66,7 @@ If no budget is supplied, RefMint uses **26 random characters / 130 bits**. Choo | Canonical reference | Valid; unchanged storage key | | Whole code in uppercase; exact groups of four | Valid; normalized to the same storage key | | Mixed case, an unsupported character or broken grouping | Rejected | +| Unicode lookalikes such as `K` in place of `K` | `character`; never silently converted to ASCII | | A payload or prefix change without recomputing the checksum | Rejected when the checksum detects the error | | Valid reference generated for another prefix | `prefix` | | Valid reference with a different payload length | `length` | @@ -94,6 +95,7 @@ RefMint has six checksum characters of overhead and does not sort by time. It ma pnpm check # build, standard vectors, independent oracle, mutation + type tests pnpm example # actual lookup example 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 pnpm test:browser # actual form flow in three browser engines pnpm pack --pack-destination artifacts @@ -101,6 +103,8 @@ 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. + ## License Original code: **MIT OR Apache-2.0**, at your option. Standard checksum/reference attribution and separately licensed test vectors are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/SPEC.md b/SPEC.md index 4a001ad..6e3c608 100644 --- a/SPEC.md +++ b/SPEC.md @@ -33,7 +33,7 @@ For one code `n=1`, pairwise collision probability is zero and the planner still `parse` accepts unknown input and returns a discriminated result; malformed input does not throw. Configuration errors and unavailable random sources do throw. 1. Non-string → `type`; raw input longer than 128 UTF-16 code units → `too_long` before further work. -2. Trim surrounding ASCII space, tab, CR and LF. Unicode whitespace is not silently removed. +2. Any non-ASCII character → `character`, before case folding. This includes Unicode whitespace and lookalikes such as `K`; they cannot normalize to an ASCII code. Trim surrounding ASCII space, tab, CR and LF. 3. Mixed upper/lowercase letters → `case`. Uniform uppercase is normalized to lowercase. 4. Find the last `1` and validate prefix syntax; malformed prefix/separator → `format`. 5. Ungrouped body is accepted. If hyphens occur, every group except the last must contain exactly four characters; last group must contain 1–4. Only these hyphens are removed; malformed groups → `format`. @@ -55,6 +55,8 @@ Errors identify a validation stage, not necessarily the user's exact mistake. A Options: `{ prefix }` for the 26-character default; `{ prefix, randomLength }` for a fixed length; or `{ prefix, expectedCount, maxCollisionProbability }` for planning. The two sizing modes cannot be combined. Unknown options are rejected so a misspelled budget cannot silently select a default. Different prefixes have different TypeScript brands; lengths within one prefix are enforced at runtime. +The factory reads each option once during construction. Its namespace and immutable plan use that same snapshot, even when an option is supplied by a getter. Later changes to the options object do not reconfigure an existing factory. + ## Evidence limits The Bech32m specification describes checksum properties. Our finite mutation tests exercise selected fixtures; they do not independently prove all error patterns. Formatting normalization intentionally accepts case and separator differences, so those are not claimed as detected mutations. A valid checksum does not imply unique issuance, a real record, an authorized user, resistance to forgery or better human performance. A unique storage constraint and an authorization policy remain application responsibilities. diff --git a/docs/assets/evidence.svg b/docs/assets/evidence.svg index cd0f1b3..7c54241 100644 --- a/docs/assets/evidence.svg +++ b/docs/assets/evidence.svg @@ -4,7 +4,7 @@ REFMINT / ALPHA EVIDENCE Make a reference. Catch a typo. Keep one storage key. -0runtime dependencies14 + 6random + check symbols4,216mutations rejected8.6 KiBemitted JavaScript +0runtime dependencies14 + 6random + check symbols4,216mutations rejected9 KiBemitted JavaScript Generate→Format for display→Parse & check→Authorized lookup 14 random symbols: plan for 1 million codes with collision bound ≤ 10⁻⁹; default is 26. 4216 finite substitutions tested. No human-error-rate or speed advantage claimed. diff --git a/docs/evidence.json b/docs/evidence.json index f4ee919..9b2bc1b 100644 --- a/docs/evidence.json +++ b/docs/evidence.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-10-27T08:32:02.185Z", + "generated_at": "2026-10-27T08:56:50.253Z", "version": "0.1.0-alpha.1", "environment": { "node": "v24.19.0", @@ -28,9 +28,9 @@ "files": [ { "file": "dist/index.js", - "bytes": 6492, - "gzip_bytes": 2225, - "sha256": "5477d5db2026f04b8cbe8f85b406a5215ae8eef90c2d9d5ddd6193941184d222" + "bytes": 6814, + "gzip_bytes": 2358, + "sha256": "c039862817354759fc4133111e86ccad559540b23d5d2082bd3ad80c825aaf37" }, { "file": "dist/bech32m.js", @@ -39,8 +39,8 @@ "sha256": "93d78425096f7dfb58c99c008c1256256e0a48d71d8d3b49a6f89a2bed3d62c6" } ], - "total_bytes": 8850, - "summed_gzip_bytes": 3358 + "total_bytes": 9172, + "summed_gzip_bytes": 3491 }, "timings": { "method": "Single process, seven rounds after warmup; no competitor comparison. Generation includes Web Crypto and checksum; parsing includes checksum and normalization.", @@ -49,30 +49,30 @@ "rounds": 7, "warmup": 2000, "ops_per_second": [ - 370603, - 423272, - 421309, - 435002, - 432156, - 426264, - 423388 + 373641, + 420928, + 417591, + 420950, + 419686, + 417733, + 420127 ], - "median_ops_per_second": 423388 + "median_ops_per_second": 419686 }, "parse_displayed": { "iterations": 20000, "rounds": 7, "warmup": 2000, "ops_per_second": [ - 479645, - 533661, - 548733, - 540627, - 550019, - 535113, - 535506 + 485884, + 521867, + 522076, + 524402, + 523370, + 525297, + 521497 ], - "median_ops_per_second": 535506 + "median_ops_per_second": 522076 } }, "collision_model": [ diff --git a/docs/evidence.md b/docs/evidence.md index f5ec158..ca65e3f 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -6,9 +6,11 @@ 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 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 deterministic samples with 2–4 substituted positions. This is finite test evidence; it is not an exhaustive proof of the standard's error-detection properties. Case folding and valid grouping are deliberately accepted and are not counted as detected errors. +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, and a narrow viewport. Hosted results are available in the CI workflow. Local results and hosted results must be identified separately. +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. ## Size and speed diff --git a/package.json b/package.json index 92aed79..0bacdd1 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "node": ">=22" }, "sideEffects": false, + "publishConfig": { "access": "public", "tag": "alpha" }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -30,8 +31,12 @@ }, "files": [ "dist", + "docs", "README.md", "SPEC.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "SECURITY.md", "LICENSE-MIT", "LICENSE-APACHE", "THIRD_PARTY_NOTICES.md" @@ -40,6 +45,7 @@ "build": "tsc -p tsconfig.json", "test": "node --test tests/*.test.mjs", "test:types": "tsc -p tests/tsconfig.json", + "test:package": "node scripts/check-package.mjs", "check": "pnpm build && pnpm test && pnpm test:types", "example": "node examples/ticket-lookup.mjs", "evidence": "node scripts/evidence.mjs", diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 0000000..4373c8b --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,87 @@ +// Exercise the distributable, rather than importing from this checkout's dist/. +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; + +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-')); +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 ?? ''}`); +} +try { + const source = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + const packs = join(temp, 'packs'); + await mkdir(packs); + run(manager, ['pack', '--pack-destination', packs], root); + const archives = (await readdir(packs)).filter(file => file.endsWith('.tgz')); + assert.equal(archives.length, 1); + const consumer = join(temp, 'consumer'); + await mkdir(consumer); + await writeFile(join(consumer, 'package.json'), JSON.stringify({ + name: 'refmint-package-check', private: true, type: 'module', packageManager: source.packageManager, + dependencies: { refmint: 'file:' + join(packs, archives[0]).replaceAll('\\', '/') } + })); + // Offline + a fresh store: any accidental runtime dependency fails this check. + run(manager, ['install', '--offline', '--ignore-scripts', '--no-frozen-lockfile', '--store-dir', join(temp, 'store')], consumer); + const installed = join(consumer, 'node_modules', 'refmint'); + const packaged = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8')); + assert.equal(packaged.version, source.version); + assert.equal(packaged.publishConfig.tag, 'alpha'); + assert.deepEqual(packaged.dependencies ?? {}, {}); + const files = await readdir(installed, { recursive: true }); + 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); + } + const evidence = JSON.parse(await readFile(join(installed, 'docs/evidence.json'), 'utf8')); + assert.equal(evidence.version, source.version, 'Refresh evidence for this version'); + for (const file of evidence.size.files) { + const bytes = await readFile(join(installed, file.file)); + assert.equal(createHash('sha256').update(bytes).digest('hex'), file.sha256, 'Runtime changed: regenerate evidence'); + assert.equal(bytes.length, file.bytes); + } + // Markdown links/images must remain usable when read from the installed package. + for (const file of files.filter(file => file.endsWith('.md'))) { + const markdown = await readFile(join(installed, file), 'utf8'); + for (const [, target] of markdown.matchAll(/\]\(([^\s)]+)\)/g)) { + if (/^(?:[a-z][\w+.-]*:|#)/i.test(target)) continue; + const path = resolve(dirname(join(installed, file)), target.split('#')[0]); + assert.ok((await readFile(path)).length > 0, `${file} -> ${target}`); + } + } + await writeFile(join(consumer, 'smoke.mjs'), ` +import assert from 'node:assert/strict'; +import { createReferences, planReferences, collisionProbability } from 'refmint'; +const refs = createReferences({prefix:'ticket', expectedCount:1000000, maxCollisionProbability:1e-9}); +const id = refs.generate(); +assert.equal(refs.parse(refs.format(id).toUpperCase()).id, id); +assert.equal(refs.parseCanonical(refs.format(id)).error, 'non_canonical'); +assert.equal(refs.parse(id.toUpperCase().replace('K', '\\u212a')).error, 'character'); +assert.equal(planReferences({prefix:'ticket'}).randomBits, 130); +assert.ok(collisionProbability(14, 1000000) < 1e-9); +await assert.rejects(import('refmint/dist/bech32m.js'), {code:'ERR_PACKAGE_PATH_NOT_EXPORTED'}); +`); + run(join(consumer, 'smoke.mjs'), [], consumer); + await writeFile(join(consumer, 'types.mts'), ` +import { createReferences, type Reference } from 'refmint'; +const docs = createReferences({prefix:'doc'}); +const ticket: Reference<'ticket'> = createReferences({prefix:'ticket'}).generate(); +// @ts-expect-error The installed declarations must retain namespace branding. +docs.format(ticket); +// @ts-expect-error An unvalidated string is not a reference. +docs.format('anything'); +const result = docs.parse('anything'); +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 { + await rm(temp, { recursive: true, force: true }); +} diff --git a/src/index.ts b/src/index.ts index 9139195..e40aef3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,11 @@ function collisionUpperBound(length: number, count: number): number { /** Pick the shortest supported payload satisfying the conservative birthday union bound. */ export function planReferences

(options: ReferenceOptions

): ReferencePlan { + return configure(options).plan; +} + +/** Read each option once so dynamic getters cannot invalidate a factory's plan. */ +function configure

(options: ReferenceOptions

): { prefix: P; plan: ReferencePlan } { if (options === null || typeof options !== 'object') throw new TypeError('Options must be an object'); for (const key of Object.keys(options)) { if (!['prefix', 'randomLength', 'expectedCount', 'maxCollisionProbability'].includes(key)) { @@ -109,25 +114,27 @@ export function planReferences

(options: ReferenceOptions

): if (length > MAX_LENGTH) throw new RangeError('Collision budget needs more than 64 random characters'); } checkLength(length); - return Object.freeze({ + const plan = Object.freeze({ randomLength: length, randomBits: length * 5, checksumLength: 6, canonicalLength: prefix.length + 1 + length + 6, expectedCount: expectedCount ?? null, maxCollisionProbability: maxCollisionProbability ?? null, collisionUpperBound: budgeted ? collisionUpperBound(length, expectedCount!) : null, collisionProbability: budgeted ? collisionProbability(length, expectedCount!) : null }); + return { prefix, plan }; } /** Create one fixed reference namespace. Persist its prefix and payload length with your schema. */ export function createReferences(options: ReferenceOptions

): References

{ - const plan = planReferences(options); - const prefix = options.prefix; + const { prefix, plan } = configure(options); function parse(input: unknown, strict: boolean): ParseResult

{ const fail = (error: ParseError): ParseResult

=> ({ ok: false, error }); if (typeof input !== 'string') return fail('type'); // Bound work before trimming, regex matching or allocating character arrays. if (input.length > 128) return fail('too_long'); + // Unicode case folding can turn K into ASCII k. Reject it before folding. + if (/[^\x00-\x7f]/.test(input)) return fail('character'); let text = input.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, ''); if (text !== text.toLowerCase() && text !== text.toUpperCase()) return fail('case'); text = text.toLowerCase(); diff --git a/tests/bech32m.test.mjs b/tests/bech32m.test.mjs index f70d8c3..5a50540 100644 --- a/tests/bech32m.test.mjs +++ b/tests/bech32m.test.mjs @@ -55,15 +55,37 @@ test('every single-symbol payload/checksum substitution is detected in boundary }); test('deterministic samples of two, three and four substitutions are detected', () => { let seed = 789; - const next = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0); + // Test-only xorshift32 avoids the old LCG's short cycle in its low five bits. + const next = () => { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return seed >>> 0; + }; const text = encode('ticket', Array.from({ length: 26 }, (_, i) => i)); for (const count of [2, 3, 4]) { - for (let sample = 0; sample < 1000; sample++) { + const seen = new Set(); + for (let attempts = 0; seen.size < 1000; attempts++) { + assert.ok(attempts < 10000, 'mutation sampler must make progress'); const positions = new Set(); while (positions.size < count) positions.add(7 + next() % 32); const mutated = [...text]; for (const i of positions) mutated[i] = ALPHABET[(ALPHABET.indexOf(mutated[i]) + 1 + next() % 31) % 32]; - assert.equal(decode(mutated.join('')), undefined); + const changed = mutated.join(''); + seen.add(changed); + assert.equal(decode(changed), undefined); + } + } +}); +test('finite insertions, deletions and adjacent transpositions are detected', () => { + for (const length of [8, 14, 26, 64]) { + const text = encode('ticket', Array.from({ length }, (_, i) => i % 32)); + for (let i = 7; i < text.length; i++) { + assert.equal(decode(text.slice(0, i) + text.slice(i + 1)), undefined, 'deletion'); + if (i + 1 < text.length && text[i] !== text[i + 1]) { + assert.equal(decode(text.slice(0, i) + text[i + 1] + text[i] + text.slice(i + 2)), undefined, 'transposition'); + } + } + for (let i = 7; i <= text.length; i++) { + for (const c of ALPHABET) assert.equal(decode(text.slice(0, i) + c + text.slice(i)), undefined, 'insertion'); } } }); diff --git a/tests/browser/lookup.spec.mjs b/tests/browser/lookup.spec.mjs index 26f1e73..a2d4a60 100644 --- a/tests/browser/lookup.spec.mjs +++ b/tests/browser/lookup.spec.mjs @@ -26,3 +26,12 @@ test('new ticket replaces the old record and narrow viewport has no horizontal o await expect(page.locator('#result')).toContainText('no matching ticket'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); + +test('Unicode lookalike cannot resolve to the real ticket', async ({ page }) => { + await page.goto('/'); + const reference = await page.locator('#reference').textContent(); + await page.getByLabel('Ticket reference', { exact: true }).fill(reference.replace('K', '\u212a')); + await page.getByRole('button', { name: 'Find ticket', exact: true }).click(); + await expect(page.locator('#result')).toHaveAttribute('data-state', 'invalid'); + await expect(page.locator('#result')).toContainText('character this format does not use'); +}); diff --git a/tests/references.test.mjs b/tests/references.test.mjs index 5a4d2b8..6fe6d43 100644 --- a/tests/references.test.mjs +++ b/tests/references.test.mjs @@ -43,6 +43,15 @@ test('reported upper bound never rounds below the exact pair count at large budg assert.ok(BigInt(plan.collisionUpperBound * 32 ** plan.randomLength) >= exactPairs); } }); +test('factory reads a dynamic prefix once and keeps its length plan consistent', () => { + let reads = 0; + const refs = createReferences({ get prefix() { return ++reads === 1 ? 'ticket' : 'order'; } }); + assert.equal(reads, 1); + const id = refs.generate(); + assert.equal(refs.prefix, 'ticket'); + assert.equal(id.length, refs.plan.canonicalLength); + assert.equal(refs.parseCanonical(id).id, id); +}); test('invalid, incomplete, contradictory or misspelled configuration fails closed', () => { for (const options of [null, undefined, {}, { prefix: '' }, { prefix: 'Ticket' }, { prefix: 'doc_1' }, { prefix: 'a'.repeat(17) }, { prefix: 'док' }, { prefix: 'doc', randomLength: 7 }, @@ -75,13 +84,31 @@ test('input errors are distinguishable without silently correcting a wrong code' [null, 'type'], [123, 'type'], ['a'.repeat(129), 'too_long'], ['', 'format'], ['T' + id.slice(1), 'case'], [id.slice(0, 7) + 'b' + id.slice(8), 'character'], [id.slice(0, 7) + '-' + id.slice(7), 'format'], [id + '-', 'format'], - [id.slice(0, 9) + '-' + id.slice(9), 'format'], ['\u00a0' + id, 'format'], + [id.slice(0, 9) + '-' + id.slice(9), 'format'], ['\u00a0' + id, 'character'], [id.slice(0, -1) + (id.endsWith('q') ? 'p' : 'q'), 'checksum'], [fixture('order'), 'prefix'], [fixture('ticket', 15), 'length'] ]; for (const [input, error] of cases) assert.deepEqual(tickets.parse(input), { ok: false, error }, String(input)); assert.throws(() => tickets.format(fixture('order')), /prefix/); }); +test('Unicode lookalikes are rejected before any case normalization', () => { + const upper = fixture('ticket', 26).toUpperCase(); + const refs = createReferences({ prefix: 'ticket' }); + // Kelvin sign folds to ASCII k; a prefix or payload lookalike must not pass. + const inputs = [ + upper.replace('K', '\u212a'), + upper.slice(0, 7) + upper.slice(7).replace('K', '\u212a'), + upper.replace('T', '\uff34'), // Fullwidth T. + upper.replace('E', '\u0415'), // Cyrillic E. + upper + '\u200b', '\u00a0' + upper + ]; + for (const input of inputs) { + assert.notEqual(input, upper); + assert.deepEqual(refs.parse(input), { ok: false, error: 'character' }); + assert.deepEqual(refs.parseCanonical(input), { ok: false, error: 'character' }); + } + assert.equal(refs.parse(upper).ok, true); +}); test('checksum binds prefix; changing a valid prefix invalidates the original checksum', () => { assert.equal(tickets.parse(fixture().replace('ticket', 'docket')).error, 'checksum'); }); diff --git a/tsconfig.json b/tsconfig.json index 8f65c46..eb7a6f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ES2022", "DOM"], "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "verbatimModuleSyntax": true, "declaration": true, "rootDir": "src", "outDir": "dist", + "newLine": "lf", "noEmitOnError": true, "skipLibCheck": false }, "include": ["src/**/*.ts"]