diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9bb0e9b..e7bad9fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -309,6 +309,45 @@ jobs: - name: Test bindings run: yarn workspaces foreach -A -j 1 run test + test-bcrypt-supported-node: + name: Test bcrypt on supported Node ${{ matrix.node }} + needs: + - build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['10', '12'] + steps: + - uses: actions/checkout@v7 + + - name: Setup node for test dependencies + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: yarn + + - name: Install test dependencies + run: yarn install --immutable --mode=skip-build + + - name: Setup node + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + + - name: Download native bindings + uses: actions/download-artifact@v8 + with: + name: bindings-x86_64-unknown-linux-gnu + path: packages + + # Avoid the modern development toolchain when testing the published API. + - name: Test bcrypt public API and stored hashes + run: node packages/bcrypt/__tests__/supported-node.cjs + + - name: Test locally imported cancellation polyfill + run: node packages/bcrypt/__tests__/polyfill-cancellation.cjs + test-linux-x64-gnu-binding: name: Test bindings on Linux-x64-gnu - node@${{ matrix.node }} needs: @@ -558,6 +597,7 @@ jobs: - test-linux-aarch64-musl-binding - test-linux-arm-gnueabihf-binding - test-macOS-windows-binding + - test-bcrypt-supported-node - test-wasi-nodejs steps: - uses: actions/checkout@v7 diff --git a/.yarnrc.yml b/.yarnrc.yml index b913f34e..40c19591 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,10 +1,17 @@ nodeLinker: node-modules +# Link workspaces only through the workspace: protocol, so pinned previous releases +# such as bcrypt-previous (npm:@node-rs/bcrypt@1.10.9) come from the registry even +# when a workspace currently has the same version. +enableTransparentWorkspaces: false + npmRegistryServer: 'https://registry.npmjs.org/' yarnPath: .yarn/releases/yarn-4.18.1.cjs npmPreapprovedPackages: + # This repo's own packages, including previous releases pinned by compatibility tests. + - '@node-rs/*' - '@napi-rs/*' - oxlint - '@oxlint/*' diff --git a/Cargo.lock b/Cargo.lock index 7a86252e..249bb6e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,15 +8,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -63,17 +54,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "bcrypt" -version = "0.19.3" +name = "bcrypt-rust" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" -dependencies = [ - "base64 0.23.1", - "blowfish", - "getrandom 0.4.3", - "subtle", - "zeroize", -] +checksum = "ae12f64a844ed060caf135a380e6afeea3d081b5a89060d0498383ea3f07c191" [[package]] name = "bitflags" @@ -90,16 +74,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blowfish" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" -dependencies = [ - "byteorder", - "cipher", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -112,12 +86,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cc" version = "1.4.2" @@ -147,16 +115,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "crypto-common 0.2.2", - "inout", -] - [[package]] name = "cmake" version = "0.1.58" @@ -239,15 +197,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ctor" version = "1.0.13" @@ -312,7 +261,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "const-oid", - "crypto-common 0.1.6", + "crypto-common", "subtle", ] @@ -375,26 +324,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "env_filter" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" -dependencies = [ - "env_filter", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -604,15 +533,6 @@ dependencies = [ "digest", ] -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "typenum", -] - [[package]] name = "include-flate" version = "0.3.4" @@ -658,15 +578,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "hybrid-array", -] - [[package]] name = "itoa" version = "1.0.18" @@ -803,12 +714,6 @@ dependencies = [ "libc", ] -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - [[package]] name = "memchr" version = "2.8.3" @@ -899,14 +804,13 @@ name = "node-rs-bcrypt" version = "0.1.0" dependencies = [ "base64 0.23.1", - "bcrypt", - "blowfish", + "bcrypt-rust", "global_alloc", "napi", "napi-build", "napi-derive", - "quickcheck", "rand 0.10.2", + "zeroize", ] [[package]] @@ -1173,17 +1077,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quickcheck" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" -dependencies = [ - "env_logger", - "log", - "rand 0.10.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -1246,35 +1139,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "rfc6979" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 73eead9e..5e66bb0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,7 @@ resolver = "2" [workspace.dependencies] argon2-rust = { version = "1.1", default-features = false } base64 = { version = "0.23" } -bcrypt = "0.19" -blowfish = { version = "0.10", features = ["bcrypt"] } +bcrypt = { package = "bcrypt-rust", version = "1" } crc32c = { version = "0.6" } crc32fast = { version = "1.4", features = ["nightly"] } global_alloc = { path = "./crates/alloc" } @@ -26,13 +25,13 @@ napi = { version = "3", default-features = false, features = ["napi3"] } napi-build = "2" napi-derive = { version = "3", default-features = false, features = ["type-def"] } once_cell = "1" -quickcheck = "1.0" rand = "0.10" rand_core = { version = "0.10" } serde = "1.0" serde_json = "1.0" simdutf8 = "0.1" xxhash-rust = { version = "0.8", features = ["xxh32", "const_xxh32", "xxh64", "const_xxh64", "xxh3", "const_xxh3"] } +zeroize = "1" [profile.release] codegen-units = 1 lto = true diff --git a/package.json b/package.json index 7b36dcfb..0cd53982 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "ts" ], "files": [ - "packages/*/__test__/**/*.spec.ts" + "packages/*/__test__/**/*.spec.ts", + "packages/bcrypt/__tests__/**/*.spec.ts" ], "nodeArguments": [ "--import", diff --git a/packages/argon2/benchmark/argon2.ts b/packages/argon2/benchmark/argon2.ts index 23721578..90113c60 100644 --- a/packages/argon2/benchmark/argon2.ts +++ b/packages/argon2/benchmark/argon2.ts @@ -1,10 +1,10 @@ import { argon2, argon2Sync } from 'node:crypto' -import { performance } from 'node:perf_hooks' import { promisify } from 'node:util' import { argon2id as nobleArgon2id } from '@noble/hashes/argon2.js' import nodeArgon2 from 'argon2' import { argon2id as wasmArgon2id } from 'hash-wasm' +import { Bench, type Task } from 'tinybench' import { Algorithm, hashRaw, hashRawSync } from '../index.js' @@ -65,129 +65,165 @@ const nodeCryptoParams = (params: SharedParams) => ({ const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex') -const median = (values: number[]) => { - const sorted = [...values].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] -} - -const asBuffer = (value: Uint8Array) => Buffer.from(value) - const assertTag = (name: string, got: Uint8Array, expected: Buffer) => { - const actual = asBuffer(got) + const actual = Buffer.from(got) if (!actual.equals(expected)) { throw new Error(`${name}: raw tag mismatch\n got ${hex(actual)}\n want ${hex(expected)}`) } } -const timeMs = async (run: Impl['run']) => { - const start = performance.now() - const out = await run() - return { ms: performance.now() - start, out } +// A task asserts its output on every iteration — a Buffer.compare is +// sub-microsecond against an argon2 run of tens of milliseconds. +const taskFn = (impl: Impl, expected: Buffer) => async () => { + assertTag(impl.name, await impl.run(), expected) } -const interleaved = async (impls: Impl[], expected: Buffer, rounds: number) => { - for (const impl of impls) { - assertTag(`${impl.name} warmup`, await impl.run(), expected) - } - - const samples = impls.map(() => [] as number[]) - for (let round = 0; round < rounds; round++) { - // Rotate so no impl is always measured immediately after the slowest one. - for (let offset = 0; offset < impls.length; offset++) { - const i = (round + offset) % impls.length - const { ms, out } = await timeMs(impls[i].run) - assertTag(`${impls[i].name} #${round}`, out, expected) - samples[i].push(ms) - } - } +function formatLatency(ms: number): string { + if (ms >= 1) return `${ms.toFixed(2)} ms` + if (ms >= 0.001) return `${(ms * 1000).toFixed(2)} µs` + return `${(ms * 1e6).toFixed(1)} ns` +} - return impls.map((impl, i) => ({ - impl: impl.name, - 'median ms': Number(median(samples[i]).toFixed(2)), - 'min ms': Number(Math.min(...samples[i]).toFixed(2)), - rounds: samples[i].length, - })) +function formatOps(opsPerSec: number): string { + if (opsPerSec >= 1e6) return `${(opsPerSec / 1e6).toFixed(2)}M` + if (opsPerSec >= 1e3) return `${(opsPerSec / 1e3).toFixed(2)}k` + return opsPerSec.toFixed(2) } -const printTable = (title: string, rows: Array>) => { - console.log(`\n${title}`) - console.table(rows) +function report(bench: Bench, title: string) { + const rows = bench.tasks + .map((task: Task) => { + const r = task.result + // latency/throughput only exist once the task has statistics. + if (r.state !== 'completed' && r.state !== 'aborted-with-statistics') { + throw new Error(`${task.name} has no results (state: ${r.state})`) + } + return { + name: task.name, + latency: r.latency.mean, + rme: r.latency.rme, + ops: r.throughput.mean, + samples: r.latency.samplesCount, + } + }) + .sort((a, b) => a.latency - b.latency) + + // Baseline is @node-rs when the group includes it, otherwise the fastest row. + const baselineRow = rows.find((r) => r.name.startsWith('@node-rs/')) ?? rows[0] + const nameWidth = Math.max(...rows.map((r) => r.name.length)) + + console.log(`\n${title} (${rows[0].samples} iterations each)`) + console.log(` ${'implementation'.padEnd(nameWidth)} latency ± rme ops/s relative`) + for (const row of rows) { + const ratio = row.latency / baselineRow.latency + const rel = + row === baselineRow ? 'baseline' : ratio < 1 ? `${(1 / ratio).toFixed(2)}× faster` : `${ratio.toFixed(2)}× slower` + console.log( + ` ${row.name.padEnd(nameWidth)} ${formatLatency(row.latency).padStart(9)} ± ${row.rme + .toFixed(2) + .padStart(5)}% ${formatOps(row.ops).padStart(8)} ${rel}`, + ) + } } for (const { name, params, rounds } of CONFIGS) { const expected = hashRawSync(PASSWORD, nodeRsOptions(params)) - const nativeSync: Impl[] = [ - { - name: '@node-rs/argon2 hashRawSync', - run: () => hashRawSync(PASSWORD, nodeRsOptions(params)), - }, - { - name: 'node:crypto argon2Sync', - run: () => argon2Sync('argon2id', nodeCryptoParams(params)), - }, - ] - - const nativeAsync: Impl[] = [ + const groups: Array<{ title: string; impls: Impl[] }> = [ { - name: '@node-rs/argon2 hashRaw', - run: () => hashRaw(PASSWORD, nodeRsOptions(params)), + title: `${name} — native sync raw`, + impls: [ + { + name: '@node-rs/argon2 hashRawSync', + run: () => hashRawSync(PASSWORD, nodeRsOptions(params)), + }, + { + name: 'node:crypto argon2Sync', + run: () => argon2Sync('argon2id', nodeCryptoParams(params)), + }, + ], }, { - name: 'node-argon2 hash raw', - run: () => - nodeArgon2.hash(PASSWORD, { - type: nodeArgon2.argon2id, - memoryCost: params.memoryCost, - timeCost: params.timeCost, - parallelism: params.parallelism, - hashLength: params.outputLen, - salt: SALT, - version: 0x13, - raw: true, - }), + title: `${name} — native async raw`, + impls: [ + { + name: '@node-rs/argon2 hashRaw', + run: () => hashRaw(PASSWORD, nodeRsOptions(params)), + }, + { + name: 'node-argon2 hash raw', + run: () => + nodeArgon2.hash(PASSWORD, { + type: nodeArgon2.argon2id, + memoryCost: params.memoryCost, + timeCost: params.timeCost, + parallelism: params.parallelism, + hashLength: params.outputLen, + salt: SALT, + version: 0x13, + raw: true, + }), + }, + { + name: 'node:crypto argon2', + run: async () => Buffer.from(await argon2Async('argon2id', nodeCryptoParams(params))), + }, + ], }, { - name: 'node:crypto argon2', - run: async () => Buffer.from(await argon2Async('argon2id', nodeCryptoParams(params))), + title: `${name} — js/wasm raw`, + impls: [ + { + name: 'hash-wasm argon2id binary', + run: () => + wasmArgon2id({ + password: PASSWORD, + salt: SALT, + parallelism: params.parallelism, + iterations: params.timeCost, + memorySize: params.memoryCost, + hashLength: params.outputLen, + outputType: 'binary', + }), + }, + { + name: '@noble/hashes argon2id', + run: () => + nobleArgon2id(PASSWORD, SALT, { + t: params.timeCost, + m: params.memoryCost, + p: params.parallelism, + dkLen: params.outputLen, + maxmem: 2 ** 32 - 1, + }), + }, + ], }, ] - const jsWasm: Impl[] = [ - { - name: 'hash-wasm argon2id binary', - run: () => - wasmArgon2id({ - password: PASSWORD, - salt: SALT, - parallelism: params.parallelism, - iterations: params.timeCost, - memorySize: params.memoryCost, - hashLength: params.outputLen, - outputType: 'binary', - }), - }, - { - name: '@noble/hashes argon2id', - run: () => - nobleArgon2id(PASSWORD, SALT, { - t: params.timeCost, - m: params.memoryCost, - p: params.parallelism, - dkLen: params.outputLen, - maxmem: 2 ** 32 - 1, - }), - }, - ] - - for (const impl of [...nativeSync, ...nativeAsync, ...jsWasm]) { - assertTag(impl.name, await impl.run(), expected) + // Pre-flight: every implementation must produce the same tag before timing. + for (const group of groups) { + for (const impl of group.impls) { + assertTag(impl.name, await impl.run(), expected) + } + } + console.log(`${name} tag=${hex(expected)} all impls equal ${rounds} iterations each`) + + for (const group of groups) { + const bench = new Bench({ + name: group.title, + // Fixed iteration count like the old harness: no time budget, no warmup + // (the pre-flight assertion pass above already warmed each impl). + iterations: rounds, + time: 0, + warmup: false, + // A tag assertion that fails must fail the run loudly. + throws: true, + }) + for (const impl of group.impls) { + bench.add(impl.name, taskFn(impl, expected)) + } + await bench.run() + report(bench, group.title) } - - console.log(`${name} tag=${hex(expected)} all impls equal interleaved x${rounds}`) - - printTable(`${name} — native sync raw`, await interleaved(nativeSync, expected, rounds)) - printTable(`${name} — native async raw`, await interleaved(nativeAsync, expected, rounds)) - printTable(`${name} — js/wasm raw`, await interleaved(jsWasm, expected, rounds)) } diff --git a/packages/bcrypt/CHANGELOG.md b/packages/bcrypt/CHANGELOG.md index d4c33df1..387ce4da 100644 --- a/packages/bcrypt/CHANGELOG.md +++ b/packages/bcrypt/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2.0.0 (Unreleased) + +Existing stored hashes keep their verification results; no rewrite or password reset is needed. See [MIGRATION.md](./MIGRATION.md). + +### BREAKING CHANGES + +- Calls take an options object instead of positional arguments: `hash(password, { cost, salt, version, rejectLongPasswords, signal })`, `genSalt({ cost, version, signal })` and `verify(password, hash, { signal })`. Positional arguments and unknown options throw `TypeError`. +- String salts must be canonical 29-character bcrypt salts, which supply their own cost and version. Raw salts must be exactly 16 bytes. 1.x clipped or padded string salts as raw text. +- `genSalt` returns 29-character salts without `==` padding, and `2x` can no longer be generated. +- Costs must be integers from 4 to 31. Fractional and overflowing values are rejected instead of truncated. +- Async validation errors reject the returned Promise. Wrong option types throw `TypeError` with `code: 'ERR_INVALID_ARG_TYPE'`; out-of-range or malformed values throw `RangeError` with `code: 'ERR_OUT_OF_RANGE'`. +- Aborting rejects with an `AbortError` (`code: 'ABORT_ERR'`, like Node's) whose `cause` is `signal.reason`, also when native work is already running. The 1.x `code: 'Cancelled'` is gone. +- Stored hashes that are not valid UTF-8 make verification return `false` instead of throwing. +- The package declares `exports`; only the package root and `package.json` can be imported. +- Loading over a platform package from another major fails at import time with `code: 'ERR_BCRYPT_INCOMPATIBLE_BINARY'`. + +### Features + +- `parseOptions(hash)` returns the `version` and `cost` of a stored hash using the verifier's parser, for rehash-on-login checks. +- `rejectLongPasswords: true` rejects passwords longer than 72 bytes when creating a hash. +- Async calls copy byte inputs before returning. Signals keep their existing handlers and can be shared between calls. +- Signals from locally imported `AbortController` polyfills are accepted on Node 10 and 12. +- The browser entry uses the same public API as Node. + ## [1.10.8](https://github.com/napi-rs/node-rs/compare/%40node-rs%2Fbcrypt%401.10.7...%40node-rs%2Fbcrypt%401.10.8) (2026-08-13) ### Bug Fixes diff --git a/packages/bcrypt/Cargo.toml b/packages/bcrypt/Cargo.toml index da06bc31..c650364e 100644 --- a/packages/bcrypt/Cargo.toml +++ b/packages/bcrypt/Cargo.toml @@ -10,14 +10,11 @@ crate-type = ["cdylib"] [dependencies] base64 = { workspace = true } bcrypt = { workspace = true } -blowfish = { workspace = true } global_alloc = { workspace = true } napi = { workspace = true, default-features = false, features = ["napi3"] } napi-derive = { workspace = true } rand = { workspace = true } - -[dev-dependencies] -quickcheck = { workspace = true } +zeroize = { workspace = true } [build-dependencies] napi-build = { workspace = true } diff --git a/packages/bcrypt/MIGRATION.md b/packages/bcrypt/MIGRATION.md new file mode 100644 index 00000000..f8e045f1 --- /dev/null +++ b/packages/bcrypt/MIGRATION.md @@ -0,0 +1,52 @@ +# Migrating to bcrypt 2 + +Stored password hashes remain usable after migrating API calls. Verification continues to use the salt and cost embedded in each stored hash, including hashes created through older custom-salt bugs. Do not rewrite hashes, replace their prefixes, or reset passwords for this upgrade. + +## Calls use options objects + +| 1.x | 2.x | +| -------------------------------------- | ------------------------------------------------- | +| `hash(password, 12)` | `hash(password, { cost: 12 })` | +| `hashSync(password, 12, rawSalt)` | `hashSync(password, { cost: 12, salt: rawSalt })` | +| `genSalt(12, '2b', signal)` | `genSalt({ cost: 12, version: '2b', signal })` | +| `verify(password, storedHash, signal)` | `verify(password, storedHash, { signal })` | +| `verify(password, storedHash)` | Unchanged | +| `compareSync(password, storedHash)` | Unchanged | + +Omit unused options instead of passing `null`. Unsupported positional arguments, unknown options, and a bare signal where options are expected fail explicitly. All async validation errors now reject the returned Promise, so use `await` inside `try/catch` or attach `.catch()`. + +Errors carry Node-style codes: `TypeError` has `code: 'ERR_INVALID_ARG_TYPE'`, `RangeError` has `code: 'ERR_OUT_OF_RANGE'`, and cancellation rejects with `name: 'AbortError'` and `code: 'ABORT_ERR'`. Branch on `code` rather than on messages. + +## Salt creation is corrected + +Raw salts must be exactly 16 bytes. String salts must be canonical encoded salts containing their cost and version, for example the result of `genSalt({ cost: 12 })`. Do not also specify cost/version when supplying an encoded salt. Generated salts now contain 29 characters without `==` padding. + +Old versions treated string salts as raw text and clipped or zero-padded them. Correct interpretation intentionally changes newly computed output. Applications that authenticate by recomputing a hash from a separately saved original salt should switch to `verify(password, storedHash)`. The stored hash contains the actual salt used previously; it needs no conversion. Automatic random salts are the default for new hashes. + +To raise the cost of existing hashes over time, check `parseOptions(storedHash)` after a successful `verify` and recompute with `hash(password, { cost })` when the stored `cost` or `version` is below your policy. `parseOptions` uses the verifier's parser, so it reads every hash `verify` accepts, including imported `2x` labels and the `+4` cost spelling; hashes `verify` always rejects throw `RangeError`. + +Costs must be finite integers in 4–31. Fractional and overflowing values are rejected instead of truncated or wrapped. Existing hashes still use their embedded effective costs; there is no new default verification cost ceiling. + +Creation no longer accepts `2x`, including inside encoded salt strings. Verification retains its previous prefix handling. That existing behavior does not implement the historical sign-extension algorithm of genuine `2x` hashes; this release neither reinterprets those hashes nor tries multiple algorithms. + +## Password bytes and login compatibility + +Default 72-byte truncation remains in both hash creation and verification. This preserves existing logins and ordinary rehash-on-login flows. No compatibility flag is necessary. UTF-8 string encoding, raw byte inputs, embedded NULs, and empty passwords keep their previous meaning. + +`rejectLongPasswords: true` is an optional creation policy. It rejects more than 72 bytes and accepts exactly 72. Enabling it can affect enrollment or rehashing of long passwords; verification never adopts it automatically. Stored bcrypt strings do not record whether the original password was truncated. + +Invalid UTF-8 bytes supplied as the stored hash now return `false`, consistently with other malformed hash data. Successfully accepted noncanonical encodings, including the `+4` cost spelling, remain accepted by verification but are rejected for new salt creation. + +## Cancellation and byte ownership + +Async calls copy mutable byte inputs before returning. Changing a password, raw salt, or stored-hash array afterward no longer changes the queued operation. + +Put `signal` in async options. A pre-aborted signal rejects before native work is queued. Later abort rejects the pending public Promise with `AbortError`, whose `cause` is the signal's `reason` when it has one; queued work is cancelled where possible, while running native computation may finish with its result discarded. Existing signal handlers are preserved, shared/reused signals work independently, and abort after observed completion has no effect. + +Native signals and compatible signals from locally imported polyfills are accepted. On Node 10 and 12, import an `AbortController` polyfill and pass `controller.signal`; neither constructor needs to be installed globally. + +Install the matching 2.x platform packages together with the root package. A backend contract check rejects stale binaries at load time with `code: 'ERR_BCRYPT_INCOMPATIBLE_BINARY'` rather than silently interpreting new calls with old native arguments. + +## Package entry points + +The package now declares `exports`, so only `@node-rs/bcrypt` itself (and its `package.json`) can be imported. Deep imports such as `@node-rs/bcrypt/binding` or `@node-rs/bcrypt/index.js` fail; import the package root instead. diff --git a/packages/bcrypt/README.md b/packages/bcrypt/README.md index 869b7171..4ae08471 100644 --- a/packages/bcrypt/README.md +++ b/packages/bcrypt/README.md @@ -8,32 +8,49 @@ ## Usage ```typescript -export const DEFAULT_COST: 12 - -export function hashSync(password: string | Buffer, round?: number): string -export function hash(password: string | Buffer, round?: number): Promise -export function verifySync(password: string | Buffer, hash: string | Buffer): boolean -export function verify(password: string | Buffer, hash: string | Buffer): Promise -/** - * The same with `verifySync` - */ -export function compareSync(password: string | Buffer, hash: string | Buffer): boolean -/** - * The same with `verify` - */ -export function compare(password: string | Buffer, hash: string | Buffer): Promise - -export type Version = '2a' | '2x' | '2y' | '2b' -/** - * @param version default '2b' - */ -export function genSaltSync(round: number, version?: Version): string -/** - * @param version default '2b' - */ -export function genSalt(round: number, version?: Version): Promise +import { hash, hashSync, verify, verifySync, genSalt, compare, parseOptions } from '@node-rs/bcrypt' + +const storedHash = await hash('password', { cost: 12 }) +await verify('password', storedHash) // true + +const salt = await genSalt({ cost: 12 }) +const withExplicitSalt = hashSync('password', { salt }) +verifySync('password', withExplicitSalt) // true +await compare('password', storedHash) // alias of verify + +parseOptions(storedHash) // { version: '2b', cost: 12 } +``` + +`hash` and `hashSync` accept a string or `Uint8Array` password and an options object. `cost` defaults to 12 and must be an integer from 4 through 31. Omitted salts use 16 random bytes. `salt` can be exactly 16 raw bytes or a canonical 29-character encoded bcrypt salt; an encoded salt supplies its own cost and version, so overrides are rejected. Creation supports `2a`, `2b` (default), and `2y`. + +`genSalt` and `genSaltSync` accept `{ cost?, version? }`. `verify` and `verifySync` take the password first and the complete stored hash second. Both password and hash accept `Uint8Array`, including `Buffer`. `compare` and `compareSync` are exact aliases. See [the declarations](index.d.ts) for the complete API. + +`parseOptions` reads the `version` and `cost` of a stored hash with the same parser `verify` uses, so every hash `verify` can accept is parseable, including imported `2x` labels; hashes `verify` always rejects throw `RangeError`. Use it to decide whether a hash should be recomputed after a successful login: + +```typescript +if (await verify(password, storedHash)) { + const { cost, version } = parseOptions(storedHash) + if (cost < 12 || version !== '2b') await save(await hash(password, { cost: 12 })) +} ``` +Bcrypt uses at most 72 password bytes. That default is unchanged for hashing and verification, including existing database hashes. To reject longer passwords when creating a hash, explicitly set `rejectLongPasswords: true`. This checks bytes, not JavaScript string length, and accepts exactly 72 bytes. Verification has no length-policy option. + +Async functions accept `signal` inside their options object and report errors through Promise rejection. Synchronous functions throw. Invalid call shapes and option values of the wrong type use `TypeError` with `code: 'ERR_INVALID_ARG_TYPE'`; values of the right type that are out of range or malformed use `RangeError` with `code: 'ERR_OUT_OF_RANGE'`. Wrong passwords and malformed stored hashes return `false`. Verification retains existing accepted encodings independently of the stricter creation parser. + +```typescript +await hash('password', { cost: 12, signal: controller.signal }) +await verify('password', storedHash, { signal: controller.signal }) +``` + +An already-aborted signal prevents queueing. Aborting a pending operation rejects with `name: 'AbortError'` and `code: 'ABORT_ERR'`, whose `cause` is `signal.reason` when the signal provides one (for example the `TimeoutError` from `AbortSignal.timeout()`); native work that has already started may finish in the background. Shared and reused signals are supported without replacing existing handlers. The first observed completion or abort determines the result. + +On Node versions without built-in cancellation, pass a signal from a locally imported `AbortController` polyfill. No global installation is required. Signals must provide a boolean `aborted` property and `addEventListener`/`removeEventListener` methods for the `abort` event; see `AbortSignalLike` in the declarations. + +The browser entry uses the same public wrapper and aliases over WASI. The WASI backend is not installed by default; for browser builds, add `@node-rs/bcrypt-wasm32-wasi` at the same version as `@node-rs/bcrypt`. Only the package root is exported. Platform-specific backend packages and `binding.js` are internal interfaces; loading over a backend from another major fails at import time with `code: 'ERR_BCRYPT_INCOMPATIBLE_BINARY'`. + +Upgrading from 1.x requires call-site changes. **Existing stored hashes do not require rewriting or password resets.** See [migration instructions](MIGRATION.md). + ## Bench ``` diff --git a/packages/bcrypt/__tests__/bcrypt.spec.ts b/packages/bcrypt/__tests__/bcrypt.spec.ts index 8ef7f505..376e46ec 100644 --- a/packages/bcrypt/__tests__/bcrypt.spec.ts +++ b/packages/bcrypt/__tests__/bcrypt.spec.ts @@ -1,61 +1,278 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import test from 'ava' - +import bcryptjs from 'bcryptjs' +import previous from 'bcrypt-previous' import { - verifySync, - compareSync, + DEFAULT_COST, + genSalt, + genSaltSync, + hash, + hashSync, verify, + verifySync, compare, - hash, - genSaltSync, - genSalt, - hashSync as bcryptHashSync, -} from '../index' + compareSync, + parseOptions, +} from '../index.js' -const { hashSync } = require('bcryptjs') +const rawSalt = Buffer.from('0123456789abcdef') +const fixture = (name: string): T => + JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), 'utf8')) +const view = (bytes: Uint8Array) => Uint8Array.from([99, ...bytes, 100]).subarray(1, bytes.length + 1) +const invalidType = { instanceOf: TypeError, code: 'ERR_INVALID_ARG_TYPE' } +const outOfRange = { instanceOf: RangeError, code: 'ERR_OUT_OF_RANGE' } +// What the encoded text itself says, to compare against the parser's reading. +const spelled = (encoded: string) => ({ version: encoded.slice(1, 3), cost: Number(encoded.slice(4, 6)) }) -const fx = Buffer.from('bcrypt-test-password') +test('generated salts compose with hashing and independent implementations', async (t) => { + t.is(DEFAULT_COST, 12) + t.regex(genSaltSync(), /^\$2b\$12\$/) + for (const version of ['2a', '2b', '2y'] as const) { + for (const salt of [genSaltSync({ cost: 4, version }), await genSalt({ cost: 4, version })]) { + t.is(salt.length, 29) + t.true(salt.startsWith(`$${version}$04$`)) + const expected = bcryptjs.hashSync('password', salt) + t.is(hashSync('password', { salt }), expected) + t.is(await hash('password', { salt }), expected) + t.true(await previous.verify('password', expected)) + t.false(previous.verifySync('wrong', expected)) + } + } + t.is(hashSync('password', { cost: 4, salt: rawSalt }), bcryptjs.hashSync('password', '$2b$04$KBCwKxOzLha2MUDgW0PjXe')) +}) -const hashedPassword = hashSync(fx.toString('utf8'), 10) +test('raw salts use the requested version', async (t) => { + for (const version of ['2a', '2b', '2y'] as const) { + const expected = bcryptjs.hashSync('password', `$${version}$04$KBCwKxOzLha2MUDgW0PjXe`) + t.is(hashSync('password', { cost: 4, salt: rawSalt, version }), expected) + t.is(await hash('password', { cost: 4, salt: rawSalt, version }), expected) + } +}) + +test('wrong option types throw TypeError while bad values throw RangeError', async (t) => { + for (const options of [{ cost: '10' }, { cost: null }, { version: 2 }, { version: null }]) { + t.throws(() => genSaltSync(options as never), invalidType) + t.throws(() => hashSync('password', options as never), invalidType) + await t.throwsAsync(genSalt(options as never), invalidType) + await t.throwsAsync(hash('password', options as never), invalidType) + } + // @ts-expect-error Exercise an unsupported version string. + t.throws(() => hashSync('password', { version: '2c' }), outOfRange) +}) + +test('creation validates costs before integer conversion', async (t) => { + for (const cost of [3, 32, 4.9, 4294967300, -4294967292, NaN, Infinity, -Infinity]) { + t.throws(() => genSaltSync({ cost }), outOfRange) + t.throws(() => hashSync('password', { cost }), outOfRange) + await t.throwsAsync(genSalt({ cost }), outOfRange) + await t.throwsAsync(hash('password', { cost }), outOfRange) + } + // Validate the upper mathematical bound without computing a cost-31 hash. + t.true(genSaltSync({ cost: 31 }).startsWith('$2b$31$')) +}) + +test('creation requires exact raw or canonical encoded salts', async (t) => { + for (const length of [0, 15, 17]) { + t.throws(() => hashSync('password', { cost: 4, salt: new Uint8Array(length) }), outOfRange) + } + const salt = '$2b$04$KBCwKxOzLha2MUDgW0PjXe' + for (const invalid of [ + '', + 'hello', + `${salt}==`, + salt.replace('2b', '2x'), + salt.replace('04', '+4'), + salt.slice(0, -1) + 'f', + salt.replace('K', 'é'), + ]) { + // Native argument errors surface with the same class and code as JavaScript validation. + t.throws(() => hashSync('password', { salt: invalid }), outOfRange) + await t.throwsAsync(hash('password', { salt: invalid }), outOfRange) + } + // @ts-expect-error Encoded salts cannot be combined with cost overrides. + t.throws(() => hashSync('password', { salt, cost: 4 }), outOfRange) + // @ts-expect-error Encoded salts cannot be combined with version overrides. + await t.throwsAsync(hash('password', { salt, version: '2b' }), outOfRange) + // @ts-expect-error 2x generation is removed. + await t.throwsAsync(genSalt({ version: '2x' }), outOfRange) +}) + +test('removed call shapes fail clearly and async validation always rejects', async (t) => { + // @ts-expect-error Positional calls are intentionally removed. + t.throws(() => hashSync('password', 4, rawSalt), invalidType) + // @ts-expect-error Positional calls are intentionally removed. + const invalid = hash('password', 4, rawSalt) + t.true(invalid instanceof Promise) + await t.throwsAsync(invalid, invalidType) + // @ts-expect-error Positional generator arguments are removed. + await t.throwsAsync(genSalt(4), invalidType) + // @ts-expect-error A bare signal must not silently become empty options. + await t.throwsAsync(verify('password', 'hash', new AbortController().signal), invalidType) + // @ts-expect-error No verification salt override. + await t.throwsAsync(verify('password', 'hash', { salt: rawSalt }), invalidType) + // @ts-expect-error Invalid runtime input must reject instead of throwing before a Promise. + await t.throwsAsync(hash(null), invalidType) + // @ts-expect-error Unknown keys must not silently choose the default cost. + await t.throwsAsync(hash('password', { rounds: 4 }), invalidType) +}) + +test('default truncation is preserved; strict creation accepts exactly 72 bytes', async (t) => { + for (const password of ['a'.repeat(71), 'a'.repeat(72), 'é'.repeat(36)]) { + const result = hashSync(password, { cost: 4, salt: rawSalt, rejectLongPasswords: true }) + t.true(verifySync(password, result)) + t.is(await hash(password, { cost: 4, salt: rawSalt, rejectLongPasswords: true }), result) + } + for (const password of ['a'.repeat(73), 'é'.repeat(37)]) { + t.throws(() => hashSync(password, { cost: 4, rejectLongPasswords: true }), outOfRange) + await t.throwsAsync(hash(password, { cost: 4, rejectLongPasswords: true }), outOfRange) + const old = previous.hashSync(password, 4) + t.true(await verify(password, old)) + const rehashed = await hash(password, { cost: 4 }) + t.true(previous.verifySync(password, rehashed)) + t.true(await verify(password, rehashed)) + } + const result = hashSync('a'.repeat(72), { cost: 4 }) + t.true(await verify('a'.repeat(72) + 'different suffix', result)) +}) + +test('async work keeps only the password bytes bcrypt reads', async (t) => { + const long = Buffer.alloc(200, 'a') + long[150] = 0x62 + const expected = hashSync(long, { cost: 4, salt: rawSalt }) + t.is(expected, bcryptjs.hashSync('a'.repeat(72), '$2b$04$KBCwKxOzLha2MUDgW0PjXe')) + t.is(await hash(view(long), { cost: 4, salt: rawSalt }), expected) + t.true(await verify(view(long), expected)) + t.true(await verify(Buffer.alloc(72, 'a'), expected)) + t.false(await verify(Buffer.alloc(71, 'a'), expected)) +}) -test('genSaltSync should return a string', (t) => { - t.is(typeof genSaltSync(10), 'string') - t.is(typeof genSaltSync(10, '2a'), 'string') - t.is(typeof genSaltSync(10, '2b'), 'string') - t.is(typeof genSaltSync(10, '2y'), 'string') - t.is(typeof genSaltSync(10, '2x'), 'string') - t.throws(() => genSaltSync(10, 'invalid' as any)) +test('published historical hashes retain authentication and byte view handling', async (t) => { + const { fixtures } = fixture<{ + fixtures: { generatorVersion: string; passwordText?: string; passwordHex: string; hash: string }[] + }>('historical-hash-fixtures') + for (const row of fixtures) { + const bytes = Buffer.from(row.passwordHex, 'hex') + const password = row.passwordText ?? bytes + const label = `${row.generatorVersion}: ${row.passwordHex}` + t.true(verifySync(password, row.hash), label) + t.true(await verify(password, row.hash), label) + t.true(verifySync(view(bytes), view(Buffer.from(row.hash))), label) + t.true(await verify(view(bytes), view(Buffer.from(row.hash))), label) + t.false(await verify(Buffer.concat([Buffer.from('!'), bytes]), row.hash), label) + t.deepEqual(parseOptions(row.hash), spelled(row.hash), label) + } }) -test('genSalt should return a string', async (t) => { - t.is(typeof (await genSalt(10)), 'string') - t.is(typeof (await genSalt(10, '2a')), 'string') - t.is(typeof (await genSalt(10, '2b')), 'string') - t.is(typeof (await genSalt(10, '2y')), 'string') - t.is(typeof (await genSalt(10, '2x')), 'string') - t.throws(() => genSalt(10, 'invalid' as any)) +test('previous-release acceptance and rejection outcomes stay frozen', async (t) => { + const { fixtures } = fixture<{ fixtures: { name: string; passwordHex: string; hash: string; expected: boolean }[] }>( + 'stored-hash-fixtures', + ) + for (const row of fixtures) { + const password = Buffer.from(row.passwordHex, 'hex') + t.is(verifySync(password, row.hash), row.expected, row.name) + t.is(await verify(password, row.hash), row.expected, row.name) + // Every hash the verifier accepts is parseable; this includes imported 2x labels. + if (row.expected) t.deepEqual(parseOptions(row.hash), spelled(row.hash), row.name) + } + const parser = fixture<{ fixtures: { name: string; password: string; hash: string; expected: boolean }[] }>( + 'verification-parser-fixtures', + ) + for (const row of parser.fixtures) { + t.is(verifySync(row.password, row.hash), row.expected, row.name) + t.is(await verify(row.password, Buffer.from(row.hash)), row.expected, row.name) + t.deepEqual(parseOptions(row.hash), { version: '2b', cost: 4 }, row.name) + } + t.false(verifySync('password', Buffer.from([255]))) + t.false(await verify('password', Buffer.from([255]))) }) -test('verifySync hashed password from bcrypt should be true', (t) => { - t.true(verifySync(fx, hashedPassword)) +test('parseOptions reads stored hashes with the verifier parser, never the creation parser', async (t) => { + for (const version of ['2a', '2b', '2y'] as const) { + for (const cost of [4, 5]) { + t.deepEqual(parseOptions(hashSync('password', { cost, version })), { version, cost }) + } + } + const encoded = await hash('password', { cost: 4 }) + t.deepEqual(parseOptions(Buffer.from(encoded)), { version: '2b', cost: 4 }) + t.deepEqual(parseOptions(view(Buffer.from(encoded))), { version: '2b', cost: 4 }) + // Noncanonical spellings the verifier accepts are reported as their effective values. + t.deepEqual(parseOptions('$2b$+4$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S'), { version: '2b', cost: 4 }) + t.deepEqual(parseOptions('$2x$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S'), { version: '2x', cost: 4 }) + // Hashes verify can never accept are errors rather than unusable parameters. + for (const invalid of [ + '', + 'not-a-hash', + encoded.slice(0, 59), + `${encoded}a`, + `$2c${encoded.slice(3)}`, + `$2b$03${encoded.slice(6)}`, + `$2b$32${encoded.slice(6)}`, + // 60 bytes, but not ASCII. + `${encoded.slice(0, 58)}é`, + Buffer.from([255]), + ]) { + t.throws(() => parseOptions(invalid), outOfRange) + t.false(verifySync('password', invalid)) + } + // @ts-expect-error Wrong input types are TypeErrors, like everywhere else. + t.throws(() => parseOptions(42), invalidType) + // @ts-expect-error There are no options to pass. + t.throws(() => parseOptions(encoded, {}), { ...invalidType, message: /single hash argument/ }) }) -test('verifySync hashed password from @node-rs/bcrypt should be true', async (t) => { - const hashed = await hash(fx) - t.true(verifySync(fx, hashed)) +test('async calls own password, salt, and stored-hash bytes before returning', async (t) => { + const password = view(Buffer.from('original')) + const salt = view(rawSalt) + const expected = hashSync('original', { cost: 4, salt: rawSalt }) + const pending = hash(password, { cost: 4, salt }) + password.fill(33) + salt.fill(0) + t.is(await pending, expected) + const input = view(Buffer.from('original')) + const encoded = view(Buffer.from(expected)) + const checking = verify(input, encoded) + input.fill(33) + encoded.fill(33) + t.true(await checking) }) -test('verifySync should always return boolean even if the password is invalid', (t) => { - t.false(verifySync('a', 'b')) - t.false(verifySync('a', '')) - t.false(verifySync('', '')) +test('pre-aborted and reused signals reject without overwriting handlers', async (t) => { + const stopped = new AbortController() + stopped.abort() + for (const operation of [ + () => genSalt({ cost: 4, signal: stopped.signal }), + () => hash('password', { cost: 4, signal: stopped.signal }), + () => verify('password', 'hash', { signal: stopped.signal }), + ]) { + await t.throwsAsync(operation(), { name: 'AbortError', code: 'ABORT_ERR' }) + } + const controller = new AbortController() + let propertyCalls = 0 + let listenerCalls = 0 + controller.signal.onabort = () => propertyCalls++ + controller.signal.addEventListener('abort', () => listenerCalls++) + await hash('completed', { cost: 4, signal: controller.signal }) + const first = hash('queued one', { cost: 4, signal: controller.signal }) + const second = hash('queued two', { cost: 4, signal: controller.signal }) + controller.abort() + await t.throwsAsync(first, { name: 'AbortError' }) + await t.throwsAsync(second, { name: 'AbortError' }) + t.is(propertyCalls, 1) + t.is(listenerCalls, 1) }) -test('compare should be equal to verify', (t) => { - t.is(verifySync, compareSync) - t.is(verify, compare) +test('comparison aliases and public exports remain consistent', (t) => { + t.is(compare, verify) + t.is(compareSync, verifySync) }) -test('hash should support long or short string', (t) => { - t.is(typeof bcryptHashSync('string', 10, 'hello'), 'string') - t.is(typeof bcryptHashSync('string', 10, 'aloooooooooooooooooooooongsalt'), 'string') +test('only the package root and package.json are exported', (t) => { + const require = createRequire(import.meta.url) + t.is(require('@node-rs/bcrypt').verify, verify) + t.is(require('@node-rs/bcrypt').parseOptions, parseOptions) + t.is(require('@node-rs/bcrypt/package.json').name, '@node-rs/bcrypt') + for (const internal of ['@node-rs/bcrypt/binding', '@node-rs/bcrypt/binding.js', '@node-rs/bcrypt/api.cjs']) { + t.throws(() => require(internal), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }) + } }) diff --git a/packages/bcrypt/__tests__/cancellation.spec.ts b/packages/bcrypt/__tests__/cancellation.spec.ts new file mode 100644 index 00000000..d5562664 --- /dev/null +++ b/packages/bcrypt/__tests__/cancellation.spec.ts @@ -0,0 +1,202 @@ +import { createRequire } from 'node:module' +import test from 'ava' +import { AbortController as PolyfillAbortController } from 'abort-controller' +import * as bcrypt from '../index.js' +import type * as API from '../index.js' + +const require = createRequire(import.meta.url) +const createBcrypt = require('../api.cjs') as (binding: Record) => typeof API +const checkPolyfillCancellation = require('./polyfill-cancellation.cjs') as (api: typeof API) => Promise + +type NativeSignal = { aborted: boolean; onabort?: () => void } +type Pending = { + signal: NativeSignal + cancellations: number + resolve: (value: string) => void + reject: (error: Error) => void +} +function controlled() { + const pending: Pending[] = [] + const api = createBcrypt({ + BCRYPT_API_VERSION: 2, + DEFAULT_COST: 12, + hash: (...args: unknown[]) => + new Promise((resolve, reject) => { + const signal = args[5] as NativeSignal + const task = { signal, cancellations: 0, resolve, reject } + signal.onabort = function () { + if (this !== signal) throw new Error('The native callback requires its original receiver') + task.cancellations++ + } + pending.push(task) + }), + }) + return { api, pending } +} + +// An older EventTarget style: the third argument is only a capture flag, and there is no reason. +function legacySignal() { + const entries: { listener: () => void; capture: boolean }[] = [] + return { + aborted: false, + entries, + addEventListener(_type: 'abort', listener: () => void, capture?: unknown) { + entries.push({ listener, capture: Boolean(capture) }) + }, + removeEventListener(_type: 'abort', listener: () => void, capture?: unknown) { + const index = entries.findIndex((entry) => entry.listener === listener && entry.capture === Boolean(capture)) + if (index !== -1) entries.splice(index, 1) + }, + abort() { + this.aborted = true + for (const { listener } of entries.slice()) listener() + }, + } +} + +test('a mismatched backend cannot silently interpret major-version calls', (t) => { + t.throws(() => createBcrypt({ DEFAULT_COST: 12 }), { + message: /Incompatible bcrypt binary/, + code: 'ERR_BCRYPT_INCOMPATIBLE_BINARY', + }) +}) + +test('aborting running work settles publicly and consumes later native failure', async (t) => { + const { api, pending } = controlled() + const controller = new AbortController() + const operation = api.hash('password', { signal: controller.signal }) + t.is(pending.length, 1) + controller.abort() + await t.throwsAsync(operation, { name: 'AbortError', code: 'ABORT_ERR' }) + t.true(pending[0].signal.aborted) + t.is(pending[0].cancellations, 1) + pending[0].reject(new Error('late native failure')) + await Promise.resolve() + t.pass() +}) + +test('abort wins if native completion has not yet been observed', async (t) => { + const { api, pending } = controlled() + const controller = new AbortController() + const operation = api.hash('password', { signal: controller.signal }) + pending[0].resolve('native result') + controller.abort() + await t.throwsAsync(operation, { name: 'AbortError' }) +}) + +test('completion wins once observed, and reused signals get independent native state', async (t) => { + const { api, pending } = controlled() + const controller = new AbortController() + const completed = api.hash('first', { signal: controller.signal }) + pending[0].resolve('completed') + t.is(await completed, 'completed') + const next = api.hash('second', { signal: controller.signal }) + t.not(pending[0].signal, pending[1].signal) + controller.abort() + await t.throwsAsync(next, { name: 'AbortError' }) + t.false(pending[0].signal.aborted) + t.true(pending[1].signal.aborted) + t.is(pending[0].cancellations, 0) + t.is(pending[1].cancellations, 1) + pending[1].resolve('discarded') + t.is(await completed, 'completed') +}) + +test('locally imported polyfill signals work alongside native globals and match the public types', async (t) => { + const controller = new PolyfillAbortController() + const options: API.AsyncOptions = { signal: controller.signal } + const encoded = bcrypt.hashSync('password', { cost: 4 }) + t.true(await bcrypt.verify('password', encoded, options)) + await checkPolyfillCancellation(bcrypt) +}) + +test('incomplete signal interfaces reject before calling the native backend', async (t) => { + const { api, pending } = controlled() + for (const signal of [null, {}, { aborted: false }, { aborted: false, addEventListener() {} }]) { + // @ts-expect-error Exercise incomplete cancellation interfaces from JavaScript. + await t.throwsAsync(api.hash('password', { signal }), { instanceOf: TypeError, code: 'ERR_INVALID_ARG_TYPE' }) + } + t.is(pending.length, 0) +}) + +test('listeners are removed with the options they were added with', async (t) => { + const { api, pending } = controlled() + const signal = legacySignal() + const completed = api.hash('first', { signal }) + t.is(signal.entries.length, 1) + pending[0].resolve('done') + t.is(await completed, 'done') + t.is(signal.entries.length, 0) + const aborted = api.hash('second', { signal }) + signal.abort() + const error = await t.throwsAsync(aborted, { name: 'AbortError' }) + t.false('cause' in error!) + t.is(signal.entries.length, 0) +}) + +test('a throwing removeEventListener cannot crash a settled call or skip native cancellation', async (t) => { + const { api, pending } = controlled() + const signal = legacySignal() + signal.removeEventListener = () => { + throw new Error('remove failed') + } + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => unhandled.push(reason) + process.on('unhandledRejection', onUnhandled) + const completed = api.hash('first', { signal }) + pending[0].resolve('done') + t.is(await completed, 'done') + const aborted = api.hash('second', { signal }) + t.notThrows(() => signal.abort()) + await t.throwsAsync(aborted, { name: 'AbortError' }) + t.is(pending[1].cancellations, 1) + await new Promise((resolve) => setImmediate(resolve)) + process.off('unhandledRejection', onUnhandled) + t.deepEqual(unhandled, []) +}) + +test('an unreadable signal reason still aborts with AbortError', async (t) => { + const { api, pending } = controlled() + const signal = legacySignal() + Object.defineProperty(signal, 'reason', { + get() { + throw new Error('unreadable') + }, + }) + const running = api.hash('password', { signal }) + t.notThrows(() => signal.abort()) + const error = await t.throwsAsync(running, { name: 'AbortError' }) + t.false('cause' in error!) + t.is(pending[0].cancellations, 1) + const preAborted = await t.throwsAsync(api.hash('password', { signal }), { name: 'AbortError' }) + t.false('cause' in preAborted!) + t.is(pending.length, 1) +}) + +test('AbortError keeps the signal reason as a non-enumerable cause', async (t) => { + const { api } = controlled() + const reason = new Error('stop') + const controller = new AbortController() + const custom = api.hash('password', { signal: controller.signal }) + controller.abort(reason) + const error = await t.throwsAsync(custom, { name: 'AbortError', code: 'ABORT_ERR' }) + t.is(error!.cause, reason) + t.false(Object.getOwnPropertyDescriptor(error, 'cause')!.enumerable) + + const plain = new AbortController() + const defaulted = api.hash('password', { signal: plain.signal }) + plain.abort() + t.is((await t.throwsAsync(defaulted, { name: 'AbortError' }))!.cause, plain.signal.reason) + + // AbortSignal.timeout() does not keep the event loop alive, and the controlled backend + // has no native work that would; hold the loop open until the timeout fires. + const keepAlive = setTimeout(() => {}, 10_000) + const timedOut = await t.throwsAsync(api.hash('password', { signal: AbortSignal.timeout(1) }), { name: 'AbortError' }) + clearTimeout(keepAlive) + t.is((timedOut!.cause as Error).name, 'TimeoutError') + + const preAborted = await t.throwsAsync(bcrypt.verify('password', 'hash', { signal: AbortSignal.abort(reason) }), { + name: 'AbortError', + }) + t.is(preAborted!.cause, reason) +}) diff --git a/packages/bcrypt/__tests__/fixtures/README.md b/packages/bcrypt/__tests__/fixtures/README.md new file mode 100644 index 00000000..115f47b0 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/README.md @@ -0,0 +1,9 @@ +# Frozen credential fixtures + +All passwords are synthetic. Do not regenerate expected hashes with the implementation under test. + +- `historical-hash-fixtures.json`: 112 hashes generated through sync/async APIs in published `@node-rs/bcrypt` 1.7.3, 1.9.2, 1.10.5, and 1.10.9. The rows record original cost/salt inputs, including old numeric coercion and text salt handling where supported. Generated on macOS arm64, Node 24.20.0. +- `stored-hash-fixtures.json`: the acceptance baseline from published 1.10.9, including long-password suffixes and both accepted and rejected legacy-prefix vectors. Generated through its WASI backend; subsequently checked against native 1.10.9 and the source build. +- `verification-parser-fixtures.json`: fixed `+4` cost cases accepted by the previous verifier. The creation parser must not gate these checks. + +The test suite additionally uses the pinned `bcrypt-previous` npm alias (1.10.9) to verify newly created hashes and checks independent known answers with bcryptjs. diff --git a/packages/bcrypt/__tests__/fixtures/historical-hash-fixtures.json b/packages/bcrypt/__tests__/fixtures/historical-hash-fixtures.json new file mode 100644 index 00000000..203326a0 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/historical-hash-fixtures.json @@ -0,0 +1,1327 @@ +{ + "node": "v24.20.0", + "arch": "arm64", + "syntheticOnly": true, + "fixtures": [ + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$pevbhWH91mTdVRUnH6qFn.Xv5wqqy9vNBg19iCegwsiCCGdVGynwC" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$RJ1c.ewclIbEK2BSA3iKm.zBFlZ2yu7p6Ifs8sNL9..iNlNr9h1SK" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.7.3", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$1Crl6Byzte9nDDNPbtuxkOmDpy.5s.AG0hToghnZLAeq7uf.WxO3y" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$PiSUlXuoPFOykK5MMFAkJupen/zjsCLuaCkmnIOMnXHOSPR5.K3ga" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.9.2", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$xN8ntGq2E6zYnCZqoVqeNOpUCdfIKZ22gWD64eLHfqD8y3S7UkA.e" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$6P3x1cWoSsXO15Q9cv/wluHITH1WWkWN.kSJvKI4UMrJKs.g.OpiC" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.5", + "method": "hashSync", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + }, + { + "generatorVersion": "1.10.5", + "method": "hash", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$Gyciqo0KI2YnbLlgNobWhu2F8.eI6WXpAqfISfG9NbgTmyEbQSAKq" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "automatic salt", + "costInput": 4, + "saltInput": null, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$xbdcBNvcCiSeUQuCanDL/uuq4tPwIkQr9tDrRYL4PzeMloI9Hoo8e" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "raw 16-byte salt", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "71-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "72-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "73-byte password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "NUL beyond 72 bytes", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000tail", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161007461696c", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "UTF-8 across truncation boundary", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "embedded NUL", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "empty password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "", + "passwordHex": "", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "binary password", + "costInput": 4, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordHex": "ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "fractional creation cost", + "costInput": 4.9, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "overflowing creation cost", + "costInput": 4294967300, + "saltInput": { + "hex": "30313233343536373839616263646566" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "empty text salt", + "costInput": 4, + "saltInput": { + "text": "" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "short text salt", + "costInput": 4, + "saltInput": { + "text": "hello" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "long text salt", + "costInput": 4, + "saltInput": { + "text": "0123456789abcdef-extra" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S" + }, + { + "generatorVersion": "1.10.9", + "method": "hashSync", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + }, + { + "generatorVersion": "1.10.9", + "method": "hash", + "name": "old padded encoded text salt", + "costInput": 4, + "saltInput": { + "text": "$2b$04$KBCwKxOzLha2MUDgW0PjXe==" + }, + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC" + } + ] +} diff --git a/packages/bcrypt/__tests__/fixtures/stored-hash-fixtures.json b/packages/bcrypt/__tests__/fixtures/stored-hash-fixtures.json new file mode 100644 index 00000000..b50659b5 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/stored-hash-fixtures.json @@ -0,0 +1,254 @@ +{ + "baseline": { + "package": "@node-rs/bcrypt", + "version": "1.10.9", + "date": "2026-09-10", + "node": "v25.2.1", + "syntheticOnly": true, + "arch": "x64", + "backend": "published wasm32-wasi 1.10.9 fallback, confirmed from loaded module paths" + }, + "fixtures": [ + { + "name": "ordinary internally generated salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$Iyx27HXZSB0TGLctKQvknumvyrTUQHhNLhs/Lp9QAkP5GzS/.CYeu", + "expected": true + }, + { + "name": "ordinary internally generated salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$Iyx27HXZSB0TGLctKQvknumvyrTUQHhNLhs/Lp9QAkP5GzS/.CYeu", + "expected": false + }, + { + "name": "exact raw salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "exact raw salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": false + }, + { + "name": "empty positional salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC", + "expected": true + }, + { + "name": "empty positional salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$......................2VBaBohsKe8kgA1pDEkLBJ7N/fpMWfC", + "expected": false + }, + { + "name": "short positional text salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC", + "expected": true + }, + { + "name": "short positional text salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$YETqZE6................ZE13Qk.UWq4DgW87g6gkU0fTPEFRKC", + "expected": false + }, + { + "name": "long positional text salt", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "long positional text salt: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": false + }, + { + "name": "encoded positional salt with old padding", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC", + "expected": true + }, + { + "name": "encoded positional salt with old padding: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$04$HBHgHB.yHCrAO1bJcC74R.Vr6Jf.PDm7M7k.onMahIMklf4LmZ7SC", + "expected": false + }, + { + "name": "71-byte password", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS", + "expected": true + }, + { + "name": "71-byte password: wrong prefix", + "passwordHex": "216161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXecEcm5teoM3t82pnGlBmdnf0LIM4LTCS", + "expected": false + }, + { + "name": "72-byte password", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "passwordHex": "616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": true + }, + { + "name": "72-byte password: wrong prefix", + "passwordHex": "21616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": false + }, + { + "name": "73-byte password", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": true + }, + { + "name": "73-byte password: wrong prefix", + "passwordHex": "2161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616158", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": false + }, + { + "name": "different ignored suffix still accepted", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaY", + "passwordHex": "61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616159", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeGsVs.sqS00XPrwbGRaMzHFG3UAzw0Ci", + "expected": true + }, + { + "name": "UTF-8 character across 72-byte boundary", + "passwordText": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé", + "passwordHex": "6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW", + "expected": true + }, + { + "name": "UTF-8 character across 72-byte boundary: wrong prefix", + "passwordHex": "216161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXebNO28mWJtvf85WUCI4G1pYIqqN2YCGW", + "expected": false + }, + { + "name": "multibyte long password", + "passwordText": "éééééééééééééééééééééééééééééééééééééééé", + "passwordHex": "c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXei6Hb7SlfT6nGWjyM/STFV23fOIxuXcK", + "expected": true + }, + { + "name": "multibyte long password: wrong prefix", + "passwordHex": "21c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9c3a9", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXei6Hb7SlfT6nGWjyM/STFV23fOIxuXcK", + "expected": false + }, + { + "name": "embedded NUL", + "passwordText": "a\u0000b", + "passwordHex": "610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry", + "expected": true + }, + { + "name": "embedded NUL: wrong prefix", + "passwordHex": "21610062", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeosN6pUItK875OWp7V79DHGzccSxfJry", + "expected": false + }, + { + "name": "empty password", + "passwordText": "", + "passwordHex": "", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa", + "expected": true + }, + { + "name": "empty password: wrong prefix", + "passwordHex": "21", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXeg/oaNzVlQFb3jg.67P.r1snBL8ZffHa", + "expected": false + }, + { + "name": "raw non-UTF-8 password bytes", + "passwordHex": "ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG", + "expected": true + }, + { + "name": "raw non-UTF-8 password bytes: wrong prefix", + "passwordHex": "21ffa30080", + "hash": "$2b$04$KBCwKxOzLha2MUDgW0PjXe2Kk.nYQJmuXk/gg9kg4tabiXCFscQRG", + "expected": false + }, + { + "name": "cost 5", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2b$05$KBCwKxOzLha2MUDgW0PjXeE7YXZXjTMz/ShaHBN92.DKFo9RcxxMO", + "expected": true + }, + { + "name": "cost 5: wrong prefix", + "passwordHex": "2173796e7468657469632d70617373776f7264", + "hash": "$2b$05$KBCwKxOzLha2MUDgW0PjXeE7YXZXjTMz/ShaHBN92.DKFo9RcxxMO", + "expected": false + }, + { + "name": "accepted imported 2a label", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2a$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "accepted imported 2x label", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2x$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "accepted imported 2y label", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "$2y$04$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "genuine historical 2x remains rejected", + "passwordHex": "a3", + "hash": "$2x$05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e", + "expected": false + }, + { + "name": "corrected value labeled 2x remains accepted", + "passwordHex": "a3", + "hash": "$2x$05$/OK.fbVrR/bpIqNJ5ianF.Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq", + "expected": true + }, + { + "name": "malformed text remains rejected", + "passwordText": "synthetic-password", + "passwordHex": "73796e7468657469632d70617373776f7264", + "hash": "not-a-hash", + "expected": false + } + ] +} diff --git a/packages/bcrypt/__tests__/fixtures/verification-parser-fixtures.json b/packages/bcrypt/__tests__/fixtures/verification-parser-fixtures.json new file mode 100644 index 00000000..4109d928 --- /dev/null +++ b/packages/bcrypt/__tests__/fixtures/verification-parser-fixtures.json @@ -0,0 +1,18 @@ +{ + "baseline": "Published @node-rs/bcrypt 1.10.9, checked 2026-09-10", + "note": "Cost +4 is noncanonical but accepted by the retained verification parser. The new creation parser must reject it independently.", + "fixtures": [ + { + "name": "accepted plus-sign cost", + "password": "synthetic-password", + "hash": "$2b$+4$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": true + }, + { + "name": "plus-sign cost wrong password", + "password": "wrong", + "hash": "$2b$+4$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S", + "expected": false + } + ] +} diff --git a/packages/bcrypt/__tests__/package.json b/packages/bcrypt/__tests__/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/packages/bcrypt/__tests__/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/bcrypt/__tests__/polyfill-cancellation.cjs b/packages/bcrypt/__tests__/polyfill-cancellation.cjs new file mode 100644 index 00000000..8453c4c2 --- /dev/null +++ b/packages/bcrypt/__tests__/polyfill-cancellation.cjs @@ -0,0 +1,74 @@ +const assert = require('assert') +const { AbortController, AbortSignal } = require('abort-controller') + +module.exports = async function checkPolyfillCancellation(api) { + const encoded = api.hashSync('password', { cost: 4 }) + const operations = [ + (signal) => api.genSalt({ cost: 4, signal }), + (signal) => api.hash('password', { cost: 4, signal }), + (signal) => api.verify('password', encoded, { signal }), + ] + const isAbortError = (error) => error.name === 'AbortError' && error.code === 'ABORT_ERR' + const stopped = new AbortController() + stopped.abort() + for (const operation of operations) { + await assert.rejects(operation(stopped.signal), isAbortError) + } + + for (const operation of operations) { + const controller = new AbortController() + const signal = controller.signal + let propertyCalls = 0 + let listenerCalls = 0 + const onabort = () => propertyCalls++ + signal.onabort = onabort + signal.addEventListener('abort', () => listenerCalls++) + + // Track only listeners added by the adapter, independently of the polyfill internals. + const listeners = new Set() + const add = signal.addEventListener + const remove = signal.removeEventListener + signal.addEventListener = function (type, listener, options) { + listeners.add(listener) + return add.call(this, type, listener, options) + } + signal.removeEventListener = function (type, listener, options) { + listeners.delete(listener) + return remove.call(this, type, listener, options) + } + + assert.ok(await operation(signal)) + assert.strictEqual(signal.onabort, onabort) + assert.strictEqual(listeners.size, 0) + + const first = operation(signal) + const second = operation(signal) + assert.strictEqual(listeners.size, 2) + controller.abort() + await assert.rejects(first, isAbortError) + await assert.rejects(second, isAbortError) + assert.strictEqual(signal.onabort, onabort) + assert.strictEqual(propertyCalls, 1) + assert.strictEqual(listenerCalls, 1) + assert.strictEqual(listeners.size, 0) + } +} + +if (require.main === module) { + const api = require('../index.js') + // This runs in its own process, including on modern Node versions. + delete global.AbortController + delete global.AbortSignal + module + .exports(api) + .then(() => { + // A partial global shim must not require a matching global controller. + global.AbortSignal = AbortSignal + return module.exports(api) + }) + .then(() => console.log(`Imported polyfill cancellation passed on ${process.version}`)) + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/packages/bcrypt/__tests__/supported-node.cjs b/packages/bcrypt/__tests__/supported-node.cjs new file mode 100644 index 00000000..8eb4f2b4 --- /dev/null +++ b/packages/bcrypt/__tests__/supported-node.cjs @@ -0,0 +1,82 @@ +// Run directly on the oldest supported Node versions, without the modern test runner. +const assert = require('assert') +const bcrypt = require('../index.js') +const historical = require('./fixtures/historical-hash-fixtures.json').fixtures +const outcomes = require('./fixtures/stored-hash-fixtures.json').fixtures +const parser = require('./fixtures/verification-parser-fixtures.json').fixtures + +async function main() { + assert.strictEqual(bcrypt.DEFAULT_COST, 12) + assert.strictEqual(bcrypt.compare, bcrypt.verify) + assert.strictEqual(bcrypt.compareSync, bcrypt.verifySync) + // Resolves through "exports" on Node 12 and through "main" on Node 10. + assert.strictEqual(require('@node-rs/bcrypt').verify, bcrypt.verify) + assert.strictEqual(require('@node-rs/bcrypt').parseOptions, bcrypt.parseOptions) + assert.ok(bcrypt.genSaltSync().startsWith('$2b$12$')) + assert.ok((await bcrypt.genSalt()).startsWith('$2b$12$')) + + for (const version of ['2a', '2b', '2y']) { + const salt = await bcrypt.genSalt({ cost: 4, version }) + assert.strictEqual(salt.length, 29) + assert.ok(salt.startsWith(`$${version}$04$`)) + const hash = bcrypt.hashSync('password', { salt }) + assert.strictEqual(await bcrypt.hash('password', { salt }), hash) + assert.strictEqual(bcrypt.verifySync('password', hash), true) + assert.strictEqual(await bcrypt.verify('wrong', hash), false) + assert.deepStrictEqual(bcrypt.parseOptions(hash), { version, cost: 4 }) + assert.deepStrictEqual(bcrypt.parseOptions(Buffer.from(hash)), { version, cost: 4 }) + } + assert.deepStrictEqual(bcrypt.parseOptions('$2b$+4$KBCwKxOzLha2MUDgW0PjXeXFrSeJ6fhvcoWu3XdffwQs4TbDlPt/S'), { + version: '2b', + cost: 4, + }) + assert.throws(() => bcrypt.parseOptions('not-a-hash'), { name: 'RangeError', code: 'ERR_OUT_OF_RANGE' }) + assert.throws(() => bcrypt.parseOptions(42), { name: 'TypeError', code: 'ERR_INVALID_ARG_TYPE' }) + + const password = Buffer.from('original') + const salt = Buffer.alloc(16) + const expected = bcrypt.hashSync('original', { cost: 4, salt }) + const pending = bcrypt.hash(password, { cost: 4, salt }) + password.fill(33) + salt.fill(33) + assert.strictEqual(await pending, expected) + + const longPassword = 'a'.repeat(73) + const longHash = await bcrypt.hash(longPassword, { cost: 4 }) + assert.strictEqual(await bcrypt.verify(longPassword, longHash), true) + const outOfRange = { name: 'RangeError', code: 'ERR_OUT_OF_RANGE' } + const invalidType = { name: 'TypeError', code: 'ERR_INVALID_ARG_TYPE' } + assert.throws(() => bcrypt.hashSync(longPassword, { cost: 4, rejectLongPasswords: true }), outOfRange) + await assert.rejects(bcrypt.hash('password', { salt: 'invalid' }), outOfRange) + await assert.rejects(bcrypt.genSalt({ cost: 3 }), outOfRange) + await assert.rejects(bcrypt.genSalt({ cost: '4' }), invalidType) + const invalid = bcrypt.hash('password', 4) + assert.ok(invalid instanceof Promise) + await assert.rejects(invalid, invalidType) + await assert.rejects(bcrypt.verify('password', expected, { signal: {} }), invalidType) + + for (const row of historical) { + const input = row.passwordText === undefined ? Buffer.from(row.passwordHex, 'hex') : row.passwordText + assert.strictEqual(bcrypt.verifySync(input, row.hash), true) + assert.strictEqual(await bcrypt.verify(input, row.hash), true) + assert.deepStrictEqual(bcrypt.parseOptions(row.hash), { + version: row.hash.slice(1, 3), + cost: Number(row.hash.slice(4, 6)), + }) + } + for (const row of outcomes) { + const input = Buffer.from(row.passwordHex, 'hex') + assert.strictEqual(bcrypt.verifySync(input, row.hash), row.expected, row.name) + assert.strictEqual(await bcrypt.verify(input, row.hash), row.expected, row.name) + } + for (const row of parser) { + assert.strictEqual(bcrypt.verifySync(row.password, row.hash), row.expected, row.name) + assert.strictEqual(await bcrypt.verify(row.password, row.hash), row.expected, row.name) + } + console.log(`Bcrypt public API and stored-hash checks passed on ${process.version}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/bcrypt/api.cjs b/packages/bcrypt/api.cjs new file mode 100644 index 00000000..b22eceab --- /dev/null +++ b/packages/bcrypt/api.cjs @@ -0,0 +1,265 @@ +// Shared by the Node entry (including WASI fallback) and the browser entry. +// Keep this adapter parseable on Node 10, before any runtime feature checks. +module.exports = function createBcrypt(binding) { + // Error codes follow Node's conventions so callers can branch without matching messages. + function withCode(error, code) { + error.code = code + return error + } + const invalidType = (message) => withCode(new TypeError(message), 'ERR_INVALID_ARG_TYPE') + const outOfRange = (message) => withCode(new RangeError(message), 'ERR_OUT_OF_RANGE') + + if (binding.BCRYPT_API_VERSION !== 2) { + throw withCode( + new Error('Incompatible bcrypt binary: rebuild or reinstall the matching @node-rs/bcrypt backend'), + 'ERR_BCRYPT_INCOMPATIBLE_BINARY', + ) + } + + function arity(args, maximum, message = 'Positional bcrypt options are no longer supported') { + if (args.length > maximum) throw invalidType(message) + } + + function options(value, keys) { + if (value === undefined) return Object.create(null) + if (value === null || typeof value !== 'object') throw invalidType('options must be an object') + const prototype = Object.getPrototypeOf(value) + if (prototype !== null && prototype !== Object.prototype) throw invalidType('options must be a plain object') + const result = Object.create(null) + for (const key of Reflect.ownKeys(value)) { + if (!keys.includes(key)) throw invalidType(`Unknown bcrypt option: ${String(key)}`) + result[key] = value[key] + } + return result + } + + function bytes(value, name) { + if (typeof value === 'string') return value + if (ArrayBuffer.isView(value) && Object.prototype.toString.call(value) === '[object Uint8Array]') return value + throw invalidType(`${name} must be a string or Uint8Array`) + } + + function creation(value) { + if (value.cost !== undefined) { + if (typeof value.cost !== 'number') throw invalidType('cost must be a number') + if (!Number.isInteger(value.cost) || value.cost < 4 || value.cost > 31) { + throw outOfRange('cost must be an integer between 4 and 31') + } + } + if (value.version !== undefined) { + if (typeof value.version !== 'string') throw invalidType('version must be a string') + if (!['2a', '2b', '2y'].includes(value.version)) throw outOfRange('version must be 2a, 2b, or 2y') + } + if (value.salt !== undefined) { + bytes(value.salt, 'salt') + if (typeof value.salt === 'string') { + if (value.cost !== undefined || value.version !== undefined) + throw outOfRange('an encoded salt already supplies cost and version') + } else if (value.salt.byteLength !== 16) { + throw outOfRange('raw salt must contain exactly 16 bytes') + } + } + if (value.rejectLongPasswords !== undefined && typeof value.rejectLongPasswords !== 'boolean') { + throw invalidType('rejectLongPasswords must be a boolean') + } + return value + } + + function signal(value) { + if (value === undefined) return value + if ( + value === null || + typeof value !== 'object' || + typeof value.aborted !== 'boolean' || + typeof value.addEventListener !== 'function' || + typeof value.removeEventListener !== 'function' + ) { + throw invalidType('signal must provide aborted, addEventListener, and removeEventListener') + } + return value + } + + function nativeError(error) { + return error && error.code === 'InvalidArg' ? outOfRange(error.message) : error + } + + function sync(start) { + try { + return start() + } catch (error) { + throw nativeError(error) + } + } + + function run(userSignal, start) { + return new Promise((resolve, reject) => { + // The native binding installs an onabort callback on the object it receives. + // Give each task a private bridge, independent of the caller's signal implementation. + const nativeSignal = userSignal === undefined ? undefined : { aborted: false, onabort: undefined } + // Pass the same options when removing, for EventTargets that read them as a capture flag. + const listenerOptions = { once: true } + let settled = false + const finish = (callback, value) => { + if (settled) return + settled = true + callback(value) + if (userSignal === undefined) return + try { + userSignal.removeEventListener('abort', abort, listenerOptions) + } catch { + // The call has settled; a listener that cannot be removed is left as a no-op. + } + } + const abort = () => { + if (settled) return + // Same name and code as Node's AbortError. + const error = withCode(new Error('The operation was aborted'), 'ABORT_ERR') + error.name = 'AbortError' + let reason + try { + reason = userSignal.reason + } catch { + // An unreadable reason must not stop the abort. + } + // Matches the native Error cause option, which Node < 16.9 ignores. + if (reason !== undefined) { + Object.defineProperty(error, 'cause', { configurable: true, writable: true, value: reason }) + } + finish(reject, error) + nativeSignal.aborted = true + if (typeof nativeSignal.onabort === 'function') nativeSignal.onabort() + } + if (userSignal !== undefined) { + userSignal.addEventListener('abort', abort, listenerOptions) + if (settled || userSignal.aborted) { + abort() + return + } + } + try { + // The binding copies all byte inputs before returning this Promise. + const task = start(nativeSignal) + Promise.resolve(task).then( + (value) => finish(resolve, value), + (error) => finish(reject, nativeError(error)), + ) + } catch (error) { + finish(reject, nativeError(error)) + } + }) + } + + const GEN_SALT_KEYS = ['cost', 'version'] + const DEFAULT_COST = binding.DEFAULT_COST + const nativeGenSaltSync = binding.genSaltSync + + // genSaltSync's shared error path. inputs that the fast path below cannot + // prove valid funnel here: full options() copy, creation() validation, and + // the same native error mapping, byte for byte. + function genSaltSyncFull(input) { + const opts = creation(options(input, GEN_SALT_KEYS)) + const cost = opts.cost === undefined ? DEFAULT_COST : opts.cost + // Inlined sync(): one closure allocation less per call, same error mapping. + try { + return nativeGenSaltSync(cost, opts.version) + } catch (error) { + throw nativeError(error) + } + } + + function genSaltSync(value) { + if (arguments.length > 1) throw invalidType('Positional bcrypt options are no longer supported') + let cost = DEFAULT_COST + let version + if (value !== undefined) { + // Fast path: undefined and plain objects whose own keys are only 'cost' + // and 'version'. Anything else (non-objects, arrays, exotic prototypes, + // unknown keys) falls into genSaltSyncFull so the errors stay identical. + if (value === null || typeof value !== 'object') return genSaltSyncFull(value) + const prototype = Object.getPrototypeOf(value) + if (prototype !== null && prototype !== Object.prototype) return genSaltSyncFull(value) + // Same coverage as Reflect.ownKeys at half the cost: names include + // non-enumerable keys, symbols get their own list. + for (const key of Object.getOwnPropertyNames(value)) { + if (key !== 'cost' && key !== 'version') return genSaltSyncFull(value) + } + if (Object.getOwnPropertySymbols(value).length !== 0) return genSaltSyncFull(value) + if (value.cost !== undefined) { + cost = value.cost + if (typeof cost !== 'number') throw invalidType('cost must be a number') + if (!Number.isInteger(cost) || cost < 4 || cost > 31) { + throw outOfRange('cost must be an integer between 4 and 31') + } + } + if (value.version !== undefined) { + version = value.version + if (typeof version !== 'string') throw invalidType('version must be a string') + if (!['2a', '2b', '2y'].includes(version)) throw outOfRange('version must be 2a, 2b, or 2y') + } + } + // Inlined sync(): one closure allocation less per call, same error mapping. + try { + return nativeGenSaltSync(cost, version) + } catch (error) { + throw nativeError(error) + } + } + + async function genSalt(value) { + arity(arguments, 1) + const opts = creation(options(value, ['cost', 'version', 'signal'])) + return run(signal(opts.signal), (internal) => + binding.genSalt(opts.cost === undefined ? DEFAULT_COST : opts.cost, opts.version, internal), + ) + } + + function hashSync(password, value) { + arity(arguments, 2) + bytes(password, 'password') + const opts = creation(options(value, ['cost', 'salt', 'version', 'rejectLongPasswords'])) + return sync(() => binding.hashSync(password, opts.cost, opts.salt, opts.version, opts.rejectLongPasswords === true)) + } + + async function hash(password, value) { + arity(arguments, 2) + bytes(password, 'password') + const opts = creation(options(value, ['cost', 'salt', 'version', 'rejectLongPasswords', 'signal'])) + return run(signal(opts.signal), (internal) => + binding.hash(password, opts.cost, opts.salt, opts.version, opts.rejectLongPasswords === true, internal), + ) + } + + function verifySync(password, encoded) { + arity(arguments, 2) + bytes(password, 'password') + bytes(encoded, 'hash') + return binding.verifySync(password, encoded) + } + + async function verify(password, encoded, value) { + arity(arguments, 3) + bytes(password, 'password') + bytes(encoded, 'hash') + const opts = options(value, ['signal']) + return run(signal(opts.signal), (internal) => binding.verify(password, encoded, internal)) + } + + function parseOptions(encoded) { + arity(arguments, 1, 'parseOptions accepts a single hash argument') + bytes(encoded, 'hash') + return sync(() => binding.parseOptions(encoded)) + } + + return { + DEFAULT_COST: binding.DEFAULT_COST, + genSalt, + genSaltSync, + hash, + hashSync, + verify, + verifySync, + compare: verify, + compareSync: verifySync, + parseOptions, + } +} diff --git a/packages/bcrypt/bcrypt.wasi-browser.js b/packages/bcrypt/bcrypt.wasi-browser.js index 3a483db0..5124cc2a 100644 --- a/packages/bcrypt/bcrypt.wasi-browser.js +++ b/packages/bcrypt/bcrypt.wasi-browser.js @@ -1,62 +1,1373 @@ import { + emnapiAsyncWorkPlugin as __emnapiAsyncWorkPlugin, + emnapiTSFNPlugin as __emnapiTSFNPlugin, createOnMessage as __wasmCreateOnMessageForFsProxy, - getDefaultContext as __emnapiGetDefaultContext, - instantiateNapiModuleSync as __emnapiInstantiateNapiModuleSync, + instantiateNapiModule as __emnapiInstantiateNapiModule, WASI as __WASI, } from '@napi-rs/wasm-runtime' +import { createContext as __emnapiCreateContext } from '@emnapi/runtime' + +export const __napiBindingTarget = 'wasm32-wasi' +function __napiStampBindingTarget(exportsObject, target) { + if (Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget')) { + if (exportsObject.__napiBindingTarget === target) { + // Already ours: the root entry aliases the object it loaded, so a WASI + // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that + // is a generated loader — arrives already stamped with this same value. + return target + } + const error = new Error( + '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].', + ) + error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT' + throw error + } + if (!Object.isExtensible(exportsObject)) { + // A `#[napi(module_exports)]` hook may seal or freeze this object + // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata, + // never a reason to fail an otherwise successful load, so the stamp is + // skipped. What a consumer still sees then follows the entry point: the + // browser and deferred loaders declare `__napiBindingTarget` at module + // level and go on reporting it, while the CommonJS entries hand back this + // very object as `module.exports`, so there the value is absent. + return target + } + try { + // [[Define]], not [[Set]]: an ordinary assignment walks the prototype + // chain, so an inherited accessor could swallow the value or throw and + // fail an otherwise successful load. The descriptor is what a successful + // assignment would have produced. + Object.defineProperty(exportsObject, '__napiBindingTarget', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }) + } catch { + // Same rule as the non-extensible skip above: reporting the artifact is + // metadata, never a reason to fail an otherwise successful load. An exotic + // object (a Proxy whose defineProperty trap refuses) is skipped, not + // thrown over. + } + // The CommonJS loaders assign this return value so `cjs-module-lexer` — and + // therefore Node's CJS -> ESM named export detection — can see + // `__napiBindingTarget` statically. + return target +} const __wasi = new __WASI({ version: 'preview1', }) const __wasmUrl = new URL('./bcrypt.wasm32-wasi.wasm', import.meta.url).href -const __emnapiContext = __emnapiGetDefaultContext() +const __wasmResponse = await globalThis.fetch(__wasmUrl) +if (!__wasmResponse.ok) { + throw new Error( + 'Failed to fetch WASI module ' + + __wasmUrl + + ': ' + + __wasmResponse.status + + ' ' + + (__wasmResponse.statusText || 'Unknown Status'), + ) +} +const __wasmFile = await __wasmResponse.arrayBuffer() const __sharedMemory = new WebAssembly.Memory({ initial: 4000, maximum: 65536, shared: true, }) +const __asyncWorkPoolSize = 4 +const __workerPoolSize = Math.max(2, globalThis.navigator?.hardwareConcurrency ?? 4) + +let __emnapiContext + +const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose') +const __wasiWorkers = new Set() +// The thread manager has to be reachable *before* anything that can throw +// during load or registration. Initialization can fail after the pool has +// already spawned workers, and the rollback still has to mark their +// terminations as expected — but `__napiModule` is assigned only when +// instantiation RETURNS, so on exactly that path it is still undefined. A +// plugin factory runs while the emnapi module is being created, before the +// wasm is loaded and before any registration function runs, and its context +// carries the very same manager instance. +let __wasiThreadManager + +function __captureWasiThreadManager(context) { + if (context && context.PThread) { + __wasiThreadManager = context.PThread + } + return {} +} -const __wasmFile = await fetch(__wasmUrl).then((res) => res.arrayBuffer()) - -const { - instance: __napiInstance, - module: __wasiModule, - napiModule: __napiModule, -} = __emnapiInstantiateNapiModuleSync(__wasmFile, { - context: __emnapiContext, - asyncWorkPoolSize: 4, - wasi: __wasi, - onCreateWorker() { - const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), { - type: 'module', +function __getWasiThreadManager() { + const manager = + __wasiThreadManager !== undefined ? __wasiThreadManager : __napiModule ? __napiModule.PThread : undefined + if (manager && typeof manager.terminateWorker === 'function') { + return manager + } + return undefined +} +let __napiInstance +let __emnapiContextDestroyed = false +let __emnapiContextDestroyPromise +let __emnapiWasmEnvCleanupPrepared = false +let __emnapiWasmEnvCleanupPreparing = false +// The closer for a barrier that is parked between `…_begin` and `…_finish`, +// set only while that window is open. `__emnapiWasmEnvCleanupPreparing` cannot +// tell those two apart on its own: it is raised both for a purely synchronous +// frame — which must not be re-entered, and which nothing outside it can +// finish — and across this window, which spans real event-loop turns, so a +// caller that cannot yield can land in the middle of one. That caller can close +// this window, because `…_finish` is idempotent and joins, which is exactly +// what the single call does. See `__prepareWasmEnvCleanup`. +let __finishParkedWasmEnvCleanup +// Raised while a caller that can still yield is driving the barrier, so the +// queue it leaves behind is expected rather than lost. See +// `__reportUnreachedWasmEnvSettlements`. +let __emnapiWasmEnvCleanupYielding = false +let __emnapiWasmEnvSettlementLossReported = false +let __emnapiWasmEnvCleanupRan = false +let __emnapiWasmEnvCleanupDrained = false +let __emnapiWasmEnvCleanupDrainPromise +let __wasiDisposed = false +let __wasiAsyncWorkDrainPromise +let __wasiDisposePromise +let __completeWasiDisposal = function () {} +// Overridden by loader flavors that have a last-resort reclaim for a rollback +// that stopped short of destroying the context. See +// `__rollbackWasiInitialization`. +let __retainWasiRollbackForRetry = function () {} + +function __isThenable(value) { + return ( + value !== null && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function' + ) +} + +function __createCleanupError(errors, message) { + if (errors.length === 1) { + return errors[0] + } + const __AggregateError = globalThis.AggregateError + if (typeof __AggregateError === 'function') { + return new __AggregateError(errors, message) + } + const error = new Error(message) + error.errors = errors + return error +} + +function __attachCleanupErrors(error, cleanupErrors) { + if (cleanupErrors.length === 0) { + return error + } + const cleanupError = __createCleanupError(cleanupErrors, 'WASI binding cleanup failed') + try { + if (error && (typeof error === 'object' || typeof error === 'function')) { + if (error.cause === undefined) { + error.cause = cleanupError + if (error.cause === cleanupError) { + return error + } + } + if (Array.isArray(error.cleanupErrors)) { + error.cleanupErrors.push(cleanupError) + return error + } else { + const attachedCleanupErrors = [cleanupError] + error.cleanupErrors = attachedCleanupErrors + if (error.cleanupErrors === attachedCleanupErrors) { + return error + } + } + } + } catch {} + const aggregate = __createCleanupError([error, cleanupError], 'WASI binding initialization and cleanup failed') + try { + aggregate.cause = error + } catch {} + return aggregate +} + +function __wrapEmnapiContextDestroyForSettlement(context, prepareEnvCleanup, isPreparingEnvCleanup) { + let destroy + try { + destroy = context.destroy + } catch { + return context + } + if (typeof destroy !== 'function') { + return context + } + try { + Object.defineProperty(context, 'destroy', { + configurable: true, + enumerable: false, + writable: true, + value: function () { + // Reentered from a promise hook that fired inside the barrier: the + // frame running it destroys as soon as it returns. + if (isPreparingEnvCleanup?.()) { + return + } + prepareEnvCleanup?.() + return Reflect.apply(destroy, this, arguments) + }, }) + } catch {} + return context +} + +function __isPreparingWasmEnvCleanup() { + return __emnapiWasmEnvCleanupPreparing +} + +function __prepareWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupPrepared) { + return + } + // A handshake parked between its two halves is one this frame can close, and + // must: every caller of this function is about to destroy the context, and + // the turns the poll is waiting for will not come — an 'exit' teardown is + // the last thing the process runs, and `Context.destroy()` takes the + // environment away. Closing it here runs `…_finish`, which is the call that joins, so + // this degrades to exactly the single call below. Leaving it open instead + // destroys the context with the barrier still raised, the runtime never + // joined and the workers never drained. + const finishParked = __finishParkedWasmEnvCleanup + if (finishParked !== undefined) { + finishParked() + __reportUnreachedWasmEnvSettlements() + return + } + if (__emnapiWasmEnvCleanupPreparing) { + return + } + const prepare = __napiInstance?.exports?.napi_prepare_wasm_env_cleanup + if (typeof prepare === 'function') { + // The addon settles the promises it cancels synchronously, under a + // non-reentrant lifecycle mutex: anything a promise hook calls from in + // here must not reach this export again. + __emnapiWasmEnvCleanupPreparing = true + try { + prepare() + } finally { + __emnapiWasmEnvCleanupPreparing = false + } + __emnapiWasmEnvCleanupRan = true + __reportUnreachedWasmEnvSettlements() + } + __emnapiWasmEnvCleanupPrepared = true +} + +/** + * Say so when the barrier leaves settlements queued and nothing is left that + * could deliver them. + * + * Only the disposal chain yields the event-loop turns @emnapi/core needs to + * dispatch its queue. Every other caller of the barrier destroys in the same + * turn — a raw `Context.destroy()`, the 'exit' teardown — and + * `Context.destroy()` runs the threadsafe function's cleanup hook, which drains + * that queue with a null env and discards it. The promises those settlements + * were for then hang forever, silently. + * + * Loud, once, and never throwing: this runs from inside `Context.destroy()`, + * emnapi's own beforeExit destroy included, where throwing would take the whole + * teardown down with it. Destroying anyway is still the right trade — the queue + * is already unreachable by then. + */ +function __reportUnreachedWasmEnvSettlements() { + if (__emnapiWasmEnvCleanupYielding || __emnapiWasmEnvSettlementLossReported) { + return + } + const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending + if (typeof pending !== 'function') { + return + } + let queued + try { + queued = pending() + } catch { + return + } + if (!queued) { + return + } + __emnapiWasmEnvSettlementLossReported = true + try { + const consoleHost = globalThis.console + if (consoleHost && typeof consoleHost.error === 'function') { + consoleHost.error( + 'napi-rs: the wasm environment is being destroyed with ' + + queued + + " queued promise settlement(s). Context.destroy() discards them, so those promises never settle. Dispose with binding[Symbol.for('napi.rs.wasi.dispose')]() instead: only it yields the event-loop turns the settlements need.", + ) + } + } catch {} +} - return worker - }, - overwriteImports(importObject) { - importObject.env = { - ...importObject.env, - ...importObject.napi, - ...importObject.emnapi, - memory: __sharedMemory, +// Mirror the primitive @emnapi/core schedules its threadsafe-function dispatch +// on, so the drain turns below interleave with that dispatch instead of racing +// ahead of it on a faster queue. +const __scheduleMacrotask = (function () { + if (typeof setImmediate === 'function') { + return function (callback) { + setImmediate(callback) } - return importObject - }, - beforeInit({ instance }) { - for (const name of Object.keys(instance.exports)) { - if (name.startsWith('__napi_register__')) { - instance.exports[name]() + } + const __MessageChannel = globalThis.MessageChannel + if (typeof __MessageChannel === 'function') { + return function (callback) { + const channel = new __MessageChannel() + channel.port1.onmessage = function () { + channel.port1.onmessage = null + try { + channel.port1.close() + } catch {} + try { + channel.port2.close() + } catch {} + callback() } + channel.port2.postMessage(null) } - }, -}) + } + return function (callback) { + setTimeout(callback, 0) + } +})() + +// A real, *referenced* timer, for waits that must let the whole host make +// progress between looks — the async-work drain polls the addon rather than +// interleaving with the @emnapi/core dispatch, so a zero-delay macrotask there +// would spin the loop instead of yielding it. Falls back to the macrotask +// scheduler on a host without timers. +function __scheduleTimer(callback, delay) { + const setTimer = globalThis.setTimeout + if (typeof setTimer !== 'function') { + __scheduleMacrotask(callback) + return + } + try { + setTimer(callback, delay) + } catch { + __scheduleMacrotask(callback) + } +} + +// A real, referenced timer rather than a zero-delay macrotask, for the same +// reason the async-work drain uses one: this polls the addon instead of +// interleaving with the @emnapi/core dispatch, so a zero-delay turn would spin +// the loop instead of yielding it. +const __WASM_RUNTIME_WORK_POLL_INTERVAL_MS = 1 +// Arrivals it takes before the poll paces on the host's timers alone. One +// proves nothing: a timer armed before the host's timers stopped still fires. +const __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS = 2 +// How long a parked turn's own timer must already have been due before a +// backup that runs calls it dropped. Slack, not a deadline: a timer is due +// against the event loop's clock, which is read once per iteration, while +// these are `Date.now()` readings taken part-way through one, so the two +// drift apart by however long the loop has been inside the current iteration. +const __WASM_RUNTIME_WORK_POLL_STALL_MS = 50 +// How long a backup itself waits. What is left of it after the slack and one +// interval — 149 ms — has to cover the *two* poll turns that can separate a +// parked turn from the last backup armed while the host's timers still +// worked, so the ceiling on a single turn is half of it. See the invariant on +// `__armWasmRuntimePollStallBackup`. +const __WASM_RUNTIME_WORK_POLL_BACKUP_MS = 200 + +/** + * Pacing state for one runtime-work poll. + * + * Per poll, never per module: whether the host's timers arrive is not a + * property of the module. A host can lose its timers between two disposals, + * and in the deferred shape every instance shares this module — one healthy + * instance must not disarm the fallback for the next one. + */ +function __createWasmRuntimePollPace() { + return { + // Timers armed by *this* poll that have actually arrived. + arrivals: 0, + // The turn waiting on a timer alone *right now* — undefined whenever no + // turn is parked — and when that turn's own timer came due. + settleTurn: undefined, + turnTimerDueAt: 0, + } +} + +/** + * The backup that ends a turn whose timer is never going to arrive. + * + * Once the poll paces on the timer alone it has nothing left to fall back on + * if the host's timers stop mid-poll: the turn that armed the dead timer is + * the turn that parks, and a parked poll schedules nothing that could notice. + * So every turn arms one of these before it yields, and each one compares due + * times instead of measuring how long the parked turn has been waiting. + * + * Invariant: a parked turn is ended by the newest backup that was armed while + * the host's timers still worked, and a backup ends a turn only when that + * turn's own timer was already due a whole window before the backup itself. + * Neither half turns on how far apart the arms happen to fall — what bounds + * the rescue is how far back that newest live backup is: + * + * - *Ends it.* Hosts run timers in due order, so a backup that runs while a + * turn due a whole window earlier is still parked proves that turn's timer + * was dropped rather than merely late. That same comparison is what leaves a + * healthy host alone: there the turn's timer has already run and cleared + * `settleTurn` before any backup due after it can look. + * - *Two turns back, not one.* A turn that ended does not prove its own timer + * arrived: until `…_TRUSTED_ARRIVALS` is reached every turn arms both + * primitives and the macrotask wins, so such a turn can end with its own + * timer — and the backup armed one line before it — already dead. The + * arrival that then flips the poll onto the timer alone can itself be a + * timer armed before the host's timers died. So the turn that parks can sit + * two turns past the last live arm, and the newest live backup is due + * `…_BACKUP_MS` less *two* turn lengths after that turn's own timer. + * Arming on every turn is what holds it to two, rather than however far back + * a throttle last let one through. + * - *Ceiling.* Coverage therefore holds while two consecutive poll turns fit + * inside `…_BACKUP_MS` less the slack and one interval: 149 ms, so 74 ms + * per turn (measured: a 74 ms turn is still rescued, a 75 ms one parks). + * Past that the turn stays parked and the disposal promise never settles. + * The bound is deliberate: reaching it takes a host that drops timers + * mid-poll *and* keeps every poll turn busy for more than 74 ms, and neither + * Node nor WebContainer — the hosts that run the threaded artifact — does + * the second. + * + * The poll then goes back to arming both primitives until two fresh arrivals + * prove the timers again. A host that stops running the timers it has + * *already* accepted leaves nothing to fire, and the disposal promise stays + * pending rather than wedging the thread — the same outcome as a blocking + * closure that never returns. Unreferenced wherever the host allows it: the + * poll's own turn timers are what keep the loop alive, never these. + */ +function __armWasmRuntimePollStallBackup(pace) { + const setTimer = globalThis.setTimeout + if (typeof setTimer !== 'function') { + // Nothing to back up: `__scheduleTimer` is on the macrotask channel + // already, and that one cannot park. + return + } + // Read before arming, so this never claims to be due earlier than the timer + // actually is: a backup ends a turn only when it is provably due after it. + const dueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_BACKUP_MS + let handle + try { + handle = setTimer(() => { + const settleTurn = pace.settleTurn + if (!settleTurn || pace.turnTimerDueAt > dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS) { + // No turn is parked, or the parked one's timer came due too close to + // this backup to call it dropped — it may still arrive, and the turn + // that armed it armed a backup due a whole window after *that*. + return + } + pace.arrivals = 0 + pace.settleTurn = undefined + settleTurn() + }, __WASM_RUNTIME_WORK_POLL_BACKUP_MS) + } catch { + return + } + if (handle && typeof handle.unref === 'function') { + try { + handle.unref() + } catch {} + } +} + +/** + * One turn of the runtime-work poll. + * + * `__scheduleTimer` falls back to the macrotask scheduler when `setTimeout` is + * missing or throws, but not when it is present, returns a handle and never + * fires — fake timers in a test suite that disposes from an `afterEach`, or a + * host whose timers belong to an IO context that is already gone. That host + * would park this poll forever, and the poll is unbounded, so nothing would + * ever call `…_finish`. + * + * Arm both primitives until timers armed by this poll have arrived twice, and + * let whichever lands first end the turn; the loser resolves nothing. A host + * with working timers therefore pays the double arming for the first turn or + * two — the macrotask wins the race, but the timers behind it still arrive and + * are counted — and paces on the timer alone from then on, instead of spinning + * the loop on a zero-delay queue. A host whose timers never arrive keeps both, + * and the macrotask is what keeps the poll moving. A host whose timers stop + * after proving themselves is caught by `__armWasmRuntimePollStallBackup`, + * which ends the parked turn and puts this poll back on both. + */ +function __yieldWasmRuntimePollTurn(pace) { + // Armed before the turn yields, and by every turn: what rescues a parked + // turn has to have been armed while the host's timers still worked, and the + // turn that parks is the one whose own timer is already dead. + __armWasmRuntimePollStallBackup(pace) + return new Promise((resolve) => { + let settled = false + const settle = () => { + if (settled) { + return + } + settled = true + if (pace.settleTurn === settle) { + // Nothing is parked any more: a backup running later must not read a + // due time this turn has already answered. + pace.settleTurn = undefined + } + resolve() + } + __scheduleTimer(() => { + pace.arrivals++ + settle() + }, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS) + // Read next to the arming it describes; see + // `__armWasmRuntimePollStallBackup` for what the two due times mean. + const turnTimerDueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_INTERVAL_MS + if (pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS) { + __scheduleMacrotask(settle) + return + } + // Paced by the timer alone from here; the backup is what ends this turn if + // the timer never arrives. + pace.settleTurn = settle + pace.turnTimerDueAt = turnTimerDueAt + }) +} + +/** + * The barrier for callers that can yield: `__prepareWasmEnvCleanup` with real + * event-loop turns in the middle. + * + * `napi_prepare_wasm_env_cleanup` waits — it returns only once the addon's + * async runtime has quiesced, and on `wasm32-wasip1-threads` the thread it + * waits on is this one, the only thread that can give a running blocking + * closure the JavaScript turn *it* is waiting for. A single call there can wait + * for work that can never finish. The addon's two-phase form splits that: + * `…_begin` stops the runtime without joining and reports whether anything is + * still live, `napi_wasm_runtime_work_pending` answers that question again + * without blocking, and `…_finish` joins. The turns yielded in between are the + * entire point. + * + * The poll has no deadline, for the same reason the async-work drain below has + * none: giving up means calling `…_finish`, which joins on this thread, and the + * work it would join is the work that is waiting for a turn from this thread — + * so a bound does not end the wait, it only moves it somewhere the JavaScript + * thread can no longer be reached. A blocking closure that never returns keeps + * the disposal promise pending instead, exactly as a task whose `execute` never + * returns already keeps an *undisposed* process alive. The host contract is in + * `crates/async-runtime/README.md`: a blocking closure must never wait on a + * JavaScript turn. The process-exit path still blocks in `…_finish`, because it + * has no turns left to give (see `__prepareWasmEnvCleanup`). + * + * Feature-detected like every other export in this teardown, so an addon built + * against a napi crate that predates the split keeps the single blocking call. + * Returns nothing whenever the handshake finished without yielding, which keeps + * an idle disposal synchronous. + */ +function __prepareWasmEnvCleanupWithTurns() { + if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) { + return + } + const exports = __napiInstance?.exports + const begin = exports?.napi_prepare_wasm_env_cleanup_begin + const finish = exports?.napi_prepare_wasm_env_cleanup_finish + if (typeof begin !== 'function' || typeof finish !== 'function') { + // No split to use. The settlement drain still follows this, so the queue + // the single call leaves behind is expected rather than lost. + __emnapiWasmEnvCleanupYielding = true + try { + __prepareWasmEnvCleanup() + } finally { + __emnapiWasmEnvCleanupYielding = false + } + return + } + const workPending = exports?.napi_wasm_runtime_work_pending + // The in-flight flag stays raised across the turns below, so a `destroy()` + // from one of the JavaScript handlers they run is the same no-op it is inside + // the single call: the barrier is up and the runtime is mid-teardown, and + // destroying between the halves would strand exactly what this delivers. + __emnapiWasmEnvCleanupPreparing = true + let live + try { + live = begin() + } catch (error) { + __emnapiWasmEnvCleanupPreparing = false + throw error + } + __emnapiWasmEnvCleanupRan = true + const finishCleanup = () => { + if (__emnapiWasmEnvCleanupPrepared) { + // Already closed by a caller that could not yield — the 'exit' teardown + // reached `__prepareWasmEnvCleanup` while this poll was parked. `…_finish` + // is idempotent, but the flags it lowers are not: running it again here + // would clear a `preparing` some later barrier had raised. + return + } + __finishParkedWasmEnvCleanup = undefined + try { + finish() + } finally { + __emnapiWasmEnvCleanupPreparing = false + } + __emnapiWasmEnvCleanupPrepared = true + } + if (!live || typeof workPending !== 'function') { + finishCleanup() + return + } + // Publish the closer before yielding: from here until `finishCleanup` runs, + // a caller that cannot yield is entitled to end this handshake itself. + __finishParkedWasmEnvCleanup = finishCleanup + return (async () => { + // Unbounded, exactly like the async-work drain below. The wait ends when + // the addon reports its runtime work finished; the turns spent here are + // what let that happen at all. + const pace = __createWasmRuntimePollPace() + for (;;) { + await __yieldWasmRuntimePollTurn(pace) + try { + if (!workPending()) { + return + } + } catch { + // A trap is the only way this fails, and a trapped instance has no + // reachable work left. Stop polling and finish. + return + } + } + })().then(finishCleanup, finishCleanup) +} + +// Turns to wait for while the addon still reports queued settlements. Reaching +// zero is the only success. A counter still nonzero at this bound rejects the +// disposal as retryable (`ERR_NAPI_WASI_CLEANUP_PENDING`) rather than +// destroying the context over a still-queued settlement — the wait stays +// bounded either way. +const __WASM_ENV_CLEANUP_DRAIN_TURNS = 128 +// Without `napi_wasm_env_cleanup_pending` the queue is not observable. Fall +// back to the number of turns @emnapi/core needs to coalesce and dispatch a +// call made on this thread (two), plus a margin. +const __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS = 4 + +/** + * `napi_prepare_wasm_env_cleanup` only *queues* the promise settlements of the + * tasks it cancelled: `napi_call_threadsafe_function` appends to the + * threadsafe-function queue, and @emnapi/core dispatches that queue from a + * macrotask — two coalescing turns later, even for a call made on this very + * thread. `Context.destroy()` then runs the threadsafe function's cleanup hook, + * which drains the queue with a null env and *discards* whatever is still in it. + * + * So destroying without yielding first strands exactly the promises the barrier + * exists to settle. Yield real event-loop turns until the addon reports the + * queue empty; microtask checkpoints cannot help, no number of them lets a + * macrotask run. + * + * Returns nothing when there is nothing to wait for, which keeps disposal + * synchronous in the common case. + * + * The "already drained" flag is set only once a wait has actually finished. + * Scheduling a macrotask can fail — a host-provided or patched `setImmediate` + * that throws is enough — and a disposal that rejects stays retryable, so + * marking the drain complete up front would make the retry skip it and destroy + * the context with the barrier's settlements still queued. + * + * A wait that runs out of turns with the counter still nonzero rejects with + * `ERR_NAPI_WASI_CLEANUP_PENDING` for the same reason: at that point + * "finished" is indistinguishable from the stranding above, and destroying + * would discard the very settlement the wait was for. The rejection leaves the + * flag unset and disposal retryable. + */ +function __drainWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupDrained || !__emnapiWasmEnvCleanupRan) { + return + } + if (__emnapiWasmEnvCleanupDrainPromise) { + return __emnapiWasmEnvCleanupDrainPromise + } + const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending + const observable = typeof pending === 'function' + if (observable) { + let queued + try { + queued = pending() + } catch { + __emnapiWasmEnvCleanupDrained = true + return + } + if (!queued) { + __emnapiWasmEnvCleanupDrained = true + return + } + } + const limit = observable ? __WASM_ENV_CLEANUP_DRAIN_TURNS : __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS + const drainPromise = (async () => { + let queued = 0 + for (let turn = 0; turn < limit; turn++) { + await new Promise((resolve) => { + __scheduleMacrotask(resolve) + }) + if (!observable) { + continue + } + try { + queued = pending() + } catch { + return + } + if (!queued) { + return + } + } + if (!observable) { + // Blind wait: without `napi_wasm_env_cleanup_pending` the bound IS the + // contract — there is nothing to consult, so finishing the turns is + // finishing the drain. + return + } + // The counter is still nonzero after every turn the bound allows. The wait + // stays bounded — but claiming success here would be indistinguishable from + // the stranding this drain exists to prevent: disposal would go on to + // destroy the context, whose cleanup hook discards the still-queued + // settlement with a null env, and the promise it was for hangs forever. + // Reject instead, as a retryable cleanup failure: the drained flag stays + // unset, dispose() (and the rollback) decline to destroy, and a later + // dispose() runs the drain again — by which time the queue has usually been + // delivered. A counter that is somehow stuck nonzero therefore costs each + // attempt at most another bounded wait and a rejection, never a stranded + // promise; the process-exit teardown still reclaims the context. + const drainError = new Error( + 'the wasm environment still reports ' + + queued + + ' queued settlement(s) after ' + + limit + + ' event-loop turns; the context was not destroyed - retry dispose() to wait for the queue again', + ) + drainError.code = 'ERR_NAPI_WASI_CLEANUP_PENDING' + throw drainError + })().then( + (value) => { + // Set only when the wait actually finished AND the queue was seen empty + // (or is unobservable): a drain that timed out with settlements still + // queued rejects above and must stay repeatable. + __emnapiWasmEnvCleanupDrained = true + __emnapiWasmEnvCleanupDrainPromise = undefined + return value + }, + (error) => { + __emnapiWasmEnvCleanupDrainPromise = undefined + throw error + }, + ) + __emnapiWasmEnvCleanupDrainPromise = drainPromise + return drainPromise +} + +function __destroyEmnapiContext() { + if (__emnapiContextDestroyed || __emnapiContext === undefined) { + __emnapiContextDestroyed = true + return + } + if (__emnapiContextDestroyPromise) { + return __emnapiContextDestroyPromise + } + + __prepareWasmEnvCleanup() + if (__isPreparingWasmEnvCleanup()) { + // Reached from inside the synchronous barrier — a promise hook one of the + // settlements above ran, which is the reentrancy the destroy wrapper + // exists for. `Context.destroy()` below would hit that wrapper's in-flight + // no-op and answer `undefined`, and recording that as a completed destroy + // is what makes the frame that *did* start the barrier skip the real one + // afterwards, leaving the context retained with its cleanup hooks unrun. + // Refuse instead: nothing is flagged, and that frame destroys for real the + // moment it returns. The deferred loader carries the same backstop. A + // parked handshake cannot get here — `__prepareWasmEnvCleanup` closes one + // rather than skipping it. + return + } + const result = __emnapiContext.destroy() + if (!__isThenable(result)) { + __emnapiContextDestroyed = true + return + } + + const destroyPromise = Promise.resolve(result).then( + (value) => { + __emnapiContextDestroyed = true + return value + }, + (error) => { + __emnapiContextDestroyPromise = undefined + throw error + }, + ) + __emnapiContextDestroyPromise = destroyPromise + return destroyPromise +} + +/** + * Holds the event loop open until `work` settles. + * + * Nothing else can: the pool workers are deliberately unreferenced so an idle + * binding cannot keep a process alive, and referencing them again for the + * termination does not hold either — emnapi unreferences a worker the moment it + * reports `async-thread-ready`, which for a worker that was still starting + * lands *after* the termination began. Without a handle of its own, an + * `await dispose()` with nothing else pending exits the process with its + * promise unsettled, and everything after the `await` is skipped. + * + * The timer is cleared as soon as the work settles, so this never outlives the + * disposal that asked for it. + */ +function __keepEventLoopAliveUntil(work) { + const setTimer = globalThis.setInterval + const clearTimer = globalThis.clearInterval + if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') { + return work + } + let timer + try { + timer = setTimer(function () {}, 50) + } catch { + return work + } + const release = function () { + try { + clearTimer(timer) + } catch {} + } + return work.then( + (value) => { + release() + return value + }, + (error) => { + release() + throw error + }, + ) +} + +// How often to re-read `napi_wasm_async_work_pending` while waiting. The wait +// ends when the addon reports zero, so this only decides how promptly disposal +// notices — not how long it waits. +const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1 + +/** + * Settles this addon's outstanding `napi_async_work` before the teardown that + * would strand it. + * + * `napi_prepare_wasm_env_cleanup` does not cover async work, and nothing about + * it is observable from JavaScript: the threadless archive resolves + * `napi_*_async_work` through the `@emnapi/core` plugins, but the threaded one + * links the C `async_work.c` on the uv threadpool, so there the wasm neither + * imports nor exports those symbols and the only brackets a loader could watch + * (`_emnapi_ctx_*_waiting_request_counter`) are shared with threadsafe + * functions. The addon is the one place both flavors go through, so it answers + * for both, through the same kind of handshake the settlement drain uses: + * + * - `napi_wasm_cancel_pending_async_work()` cancels what no thread has + * started. Those completion callbacks run with `napi_cancelled`, which + * napi-rs turns into a promise rejected with an `AbortError`. + * - `napi_wasm_async_work_pending()` counts what is still owed a completion + * callback. Work already executing refuses cancellation and stays counted + * until it finishes normally — which it can, because this runs before the + * barrier, before `Context.destroy()` and before anything is terminated. + * + * Both exports are optional: an addon built against a napi crate that predates + * them drains nothing and keeps the previous behavior, exactly as the + * `napi_wasm_env_cleanup_pending` handshake degrades. + * + * Returns nothing when there is nothing outstanding, which keeps disposal + * synchronous in the common case. The promise it returns otherwise never + * rejects. + * + * The wait has no deadline, and that is the point: giving up would destroy the + * environment with a completion callback still owed, which is the stranding + * this exists to prevent. A task whose `execute` never returns already keeps an + * *undisposed* process alive in exactly the same way, so disposal inherits that + * rather than inventing a bound it cannot honor. + * + * Safe to call from inside a completion callback, which is reachable: settling + * a task runs addon code that can re-enter JavaScript — a setter on the value + * being handed back, a threadsafe-function callback — and that JavaScript can + * call `dispose()`. Two things make it terminate rather than wait on itself: + * + * - The addon keeps a work registered until its completion callback + * *finishes*, so the count read here is at least one and this takes the + * polling path instead of declaring the environment drained and tearing it + * down from inside the frame that is still settling a promise. + * - The poll is a timer, so it cannot run until the callback has returned to + * the host — by which time that work has left the registry. The count the + * next poll reads is the one taken after the callback finished. + * + * `__disposeWasiBinding` hands every caller the same in-flight promise, so the + * nested call joins this disposal rather than starting a second one. + */ +function __drainWasiAsyncWork() { + if (__wasiAsyncWorkDrainPromise !== undefined) { + return __wasiAsyncWorkDrainPromise + } + const exports = __napiInstance?.exports + const pending = exports?.napi_wasm_async_work_pending + const cancelPending = exports?.napi_wasm_cancel_pending_async_work + if (typeof pending !== 'function' || typeof cancelPending !== 'function') { + return + } + + const readPending = () => { + try { + return pending() + } catch (error) { + // A trap is the only way this call fails: it reads a counter and cannot + // allocate or call back into JavaScript. A trapped instance can no longer + // run anything, so its outstanding work is unreachable by definition — + // there is nothing left to wait for, and refusing to dispose would only + // keep a dead instance and its stuck counter alive. Best-effort here is + // the honest answer, and it is what disposal did before this drain + // existed. + // + // Only a trap. Anything else means the export is not what this loader + // thinks it is, which is a defect worth surfacing rather than disposing + // over. + if (error instanceof globalThis.WebAssembly.RuntimeError) { + return 0 + } + throw error + } + } + + if (!readPending()) { + return + } + try { + cancelPending() + } catch { + // Cancellation is an optimization: it bounds the wait by the work already + // executing. Failing it only means waiting for the whole queue instead. + } + if (!readPending()) { + return + } + + const drainPromise = __keepEventLoopAliveUntil( + (async () => { + while (readPending()) { + await new Promise((resolve) => { + __scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS) + }) + } + })(), + ).then( + () => { + __wasiAsyncWorkDrainPromise = undefined + }, + (error) => { + // A wait that could not run is not a wait that finished. The only way + // here is a host whose timers and macrotask primitives all refuse, and + // the work is still outstanding — reporting success would destroy the + // environment over it, which is the stranding this exists to prevent. + // Reject instead: disposal stays retryable, and the context is not + // destroyed. Clearing the memo first is what makes the retry re-run this. + __wasiAsyncWorkDrainPromise = undefined + throw error + }, + ) + __wasiAsyncWorkDrainPromise = drainPromise + return drainPromise +} + +/** + * `@emnapi/wasi-threads` counts a worker exit as expected only when its own + * thread manager performed the termination. A bare `worker.terminate()` reaches + * the manager's `exit` listener instead, which reports + * `worker (tid = N) sent an error! ... stopped with exit code 1` and rethrows + * inside the emit — aborting the `once('exit')` that backs the terminate + * promise, so disposal never settles and the process dies with an uncaught + * exception. Mark the termination through the manager first. + * + * The manager comes from `__getWasiThreadManager`, not from `__napiModule`: + * the initialization rollback runs on the one path where instantiation never + * returned, so `__napiModule` is still undefined there while the workers it + * spawned are already registered and loaded. + * + * Not `terminateAllThreads()`: that one recreates the pool it just shut down. + */ +function __terminateWasiWorkers() { + const cleanupErrors = [] + const pending = [] + const threadManager = __getWasiThreadManager() + + for (const worker of __wasiWorkers) { + let result + try { + if (threadManager) { + threadManager.terminateWorker(worker) + // `terminateWorker` leaves behind a reporter that logs every message + // still queued on the port, which Node flushes on exit. Nothing is + // listening for those any more. + worker.onmessage = undefined + } + result = worker.terminate() + } catch (error) { + cleanupErrors.push(error) + continue + } + if (__isThenable(result)) { + pending.push( + Promise.resolve(result).then( + () => { + __wasiWorkers.delete(worker) + }, + (error) => { + cleanupErrors.push(error) + }, + ), + ) + } else { + __wasiWorkers.delete(worker) + } + } + + const finish = () => { + if (cleanupErrors.length > 0) { + throw __createCleanupError(cleanupErrors, 'Failed to terminate WASI workers') + } + } + return pending.length > 0 ? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish) : finish() +} + +function __finishWasiDisposal() { + const workerResult = __terminateWasiWorkers() + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult).then(__completeWasiDisposal) + } + return __completeWasiDisposal() +} + +function __continueWasiDisposal() { + const destroyResult = __destroyEmnapiContext() + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult).then(__finishWasiDisposal) + } + return __finishWasiDisposal() +} + +function __drainWasmEnvForWasiDisposal() { + const drainResult = __drainWasmEnvCleanup() + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then(__continueWasiDisposal) + } + return __continueWasiDisposal() +} + +function __cleanUpWasmEnvForWasiDisposal() { + // Run the pre-teardown barrier — yielding the turns its two-phase form asks + // for, when the addon has one — then let the settlements it queued actually + // reach JavaScript, and only then destroy the environment. Doing any two of + // these back to back is what strands them. + const prepareResult = __prepareWasmEnvCleanupWithTurns() + if (__isThenable(prepareResult)) { + return Promise.resolve(prepareResult).then(__drainWasmEnvForWasiDisposal) + } + return __drainWasmEnvForWasiDisposal() +} + +function __startWasiDisposal() { + // Outstanding `napi_async_work` goes first, while the environment is still + // completely live: the completion callbacks run addon code, and everything + // after this point takes that away from them — the barrier shuts the async + // runtime down, `Context.destroy()` stops JavaScript calls, and terminating + // the pool threads removes what would have reported the work finished. + const asyncWorkResult = __drainWasiAsyncWork() + if (__isThenable(asyncWorkResult)) { + return Promise.resolve(asyncWorkResult).then(__cleanUpWasmEnvForWasiDisposal) + } + return __cleanUpWasmEnvForWasiDisposal() +} + +/** + * Disposes this generated WASI binding. + * + * Access this function with: + * binding[Symbol.for('napi.rs.wasi.dispose')]() + */ +function __disposeWasiBinding() { + if (__wasiDisposePromise) { + return __wasiDisposePromise + } + if (__wasiDisposed) { + return Promise.resolve() + } + + let resolveDispose + let rejectDispose + const disposePromise = new Promise((resolve, reject) => { + resolveDispose = resolve + rejectDispose = reject + }) + __wasiDisposePromise = disposePromise + + let result + try { + result = __startWasiDisposal() + } catch (error) { + __wasiDisposePromise = undefined + rejectDispose(error) + return disposePromise + } + + Promise.resolve(result).then( + (value) => { + __wasiDisposed = true + resolveDispose(value) + }, + (error) => { + __wasiDisposePromise = undefined + rejectDispose(error) + }, + ) + return disposePromise +} + +function __publishWasiDispose(exports) { + Object.defineProperty(exports, __wasiDisposeSymbol, { + configurable: false, + enumerable: false, + value: __disposeWasiBinding, + writable: false, + }) +} + +function __finishWasiInitializationRollback(cleanupErrors) { + let workerResult + try { + workerResult = __terminateWasiWorkers() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return cleanupErrors + } + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => cleanupErrors) + } + return cleanupErrors +} + +function __destroyContextForWasiRollback(cleanupErrors) { + let destroyResult + try { + destroyResult = __destroyEmnapiContext() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __finishWasiInitializationRollback(cleanupErrors) + } + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => __finishWasiInitializationRollback(cleanupErrors)) + } + return __finishWasiInitializationRollback(cleanupErrors) +} + +/** + * Leaves a rollback that could not reach the queued settlements undestroyed, and + * hands it to whatever this flavor has that can still reclaim it. + */ +function __retainFailedWasiRollback(cleanupErrors) { + try { + __retainWasiRollbackForRetry() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + return cleanupErrors +} + +/** + * Initialization can fail *after* registration has already run, and registration + * runs with a live environment: a module-init hook can start async work and then + * return an error, and the promise it created may already have escaped into + * JavaScript. The barrier cancels that work and *queues* the settlement, so this + * path needs the same drain the ordinary disposal does — destroying without + * yielding discards the queue with a null env and strands the promise. + * + * Stays synchronous when nothing is queued, which covers every failure before + * `beforeInit`: there is no instance to run the barrier on, so nothing to drain. + * + * A barrier or drain that did *not* finish stops the rollback short of + * destroying, which is what `dispose()` already does — a rejected drain there + * never reaches `__continueWasiDisposal`. Destroying anyway is the worse of the + * two trades, and not because of what it saves: + * + * - It cannot deliver the settlements. `Context.destroy()` runs the + * threadsafe function's cleanup hook, which drains the queue with a null env + * and discards it, so a promise that already escaped into JavaScript hangs + * forever with nothing left that could ever settle it. + * - It saves less than it looks. `Context.destroy()` stops JavaScript calls + * and runs cleanup hooks; it does not free the wasm instance or its Memory, + * which this module's scope holds either way. What stopping short retains is + * the emnapi context's bookkeeping and its un-run cleanup hooks. + * - Retry is not theoretical. A rollback that records a cleanup error is + * already kept in the process-wide registry above, so re-`require()`ing this + * file replays it instead of re-instantiating — and the `6e15de6f` flag fix + * means the replay drains again rather than skipping it. Destroying first is + * what makes that retained record useless. + * + * The residual cost is honest: the CJS flavor hands the context to its + * `process.on('exit')` teardown, so a process that never retries still reclaims + * it on the way out. The ESM browser flavor has no equivalent — a module that + * throws while evaluating is permanently errored, so re-importing rethrows + * without re-running this file — and there the context stays until the realm + * goes away. That is the deliberate choice: a hung promise is a silent liveness + * bug with no upper bound, while the retained bookkeeping is bounded by the page. + */ +function __rollbackWasiInitialization() { + // The environment teardown this rollback performs, kept nested so it cannot + // be reached without the async-work drain below running first. + function __rollbackWasmEnvForWasiInitialization() { + const cleanupErrors = [] + let prepareResult + try { + prepareResult = __prepareWasmEnvCleanupWithTurns() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + } + if (__isThenable(prepareResult)) { + return Promise.resolve(prepareResult).then( + () => __drainWasmEnvForWasiRollback(cleanupErrors), + (cleanupError) => { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + }, + ) + } + return __drainWasmEnvForWasiRollback(cleanupErrors) + } + + // The settlement drain of the rollback above, reached either straight away or + // after the barrier's two-phase form has yielded its turns. A barrier that + // did not finish never gets here: it retains instead, exactly as a drain that + // did not finish does. + function __drainWasmEnvForWasiRollback(cleanupErrors) { + let drainResult + try { + drainResult = __drainWasmEnvCleanup() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + } + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then( + () => __destroyContextForWasiRollback(cleanupErrors), + (cleanupError) => { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + }, + ) + } + return __destroyContextForWasiRollback(cleanupErrors) + } + + // Same reason as `__startWasiDisposal`: a module-init hook can start async + // work before the load goes on to fail, and this rollback tears down exactly + // what those completions need. Settle them while everything is still live, + // before the barrier and the teardown above take that away. + // + // A drain that could not finish leaves async work possibly outstanding, and + // destroying the context over it would strand exactly what this rollback is + // there to settle. Stop short and retain instead — the same trade + // `__rollbackWasmEnvForWasiInitialization` makes for the settlement drain, so + // the context stays reclaimable by a retry or by this flavor's own + // last-resort teardown. + const __retainAfterAsyncWorkDrainFailure = (cleanupError) => __retainFailedWasiRollback([cleanupError]) + let asyncWorkResult + try { + asyncWorkResult = __drainWasiAsyncWork() + } catch (cleanupError) { + return __retainAfterAsyncWorkDrainFailure(cleanupError) + } + if (__isThenable(asyncWorkResult)) { + return Promise.resolve(asyncWorkResult).then( + __rollbackWasmEnvForWasiInitialization, + __retainAfterAsyncWorkDrainFailure, + ) + } + return __rollbackWasmEnvForWasiInitialization() +} + +let __wasiModule +let __napiModule + +try { + __emnapiContext = __wrapEmnapiContextDestroyForSettlement( + __emnapiCreateContext({ autoDestroy: false }), + __prepareWasmEnvCleanup, + __isPreparingWasmEnvCleanup, + ) + __emnapiContext.suppressDestroy() + + ;({ + instance: __napiInstance, + module: __wasiModule, + napiModule: __napiModule, + } = await __emnapiInstantiateNapiModule(__wasmFile, { + context: __emnapiContext, + asyncWorkPoolSize: __asyncWorkPoolSize, + reuseWorker: { size: __asyncWorkPoolSize + __workerPoolSize }, + plugins: [__captureWasiThreadManager, __emnapiAsyncWorkPlugin, __emnapiTSFNPlugin], + wasi: __wasi, + onCreateWorker() { + const worker = new Worker(new URL('./wasi-worker-browser.mjs', import.meta.url), { + type: 'module', + }) + __wasiWorkers.add(worker) + + return worker + }, + overwriteImports(importObject) { + importObject.env = { + ...importObject.env, + ...importObject.napi, + ...importObject.emnapi, + memory: __sharedMemory, + } + return importObject + }, + beforeInit({ instance }) { + __napiInstance = instance + for (const name of Object.keys(instance.exports)) { + if (name.startsWith('__napi_register__')) { + instance.exports[name]() + } + } + }, + })) + __publishWasiDispose(__napiModule.exports) + // The default export hands out this object; a named module export does not + // travel with it, so carry the marker on the binding itself too. After the + // host install, which hands the same object to addon-provided registration + // functions that may put anything on it, and inside this `try`, so a claimed + // name fails the load through the rollback below rather than past it. + __napiStampBindingTarget(__napiModule.exports, __napiBindingTarget) +} catch (error) { + const cleanupErrors = await __rollbackWasiInitialization() + throw __attachCleanupErrors(error, cleanupErrors) +} export default __napiModule.exports +export const BCRYPT_API_VERSION = __napiModule.exports.BCRYPT_API_VERSION export const DEFAULT_COST = __napiModule.exports.DEFAULT_COST export const genSalt = __napiModule.exports.genSalt export const genSaltSync = __napiModule.exports.genSaltSync export const hash = __napiModule.exports.hash export const hashSync = __napiModule.exports.hashSync +export const parseOptions = __napiModule.exports.parseOptions export const verify = __napiModule.exports.verify export const verifySync = __napiModule.exports.verifySync diff --git a/packages/bcrypt/bcrypt.wasi.cjs b/packages/bcrypt/bcrypt.wasi.cjs index 36d106d8..45ee292a 100644 --- a/packages/bcrypt/bcrypt.wasi.cjs +++ b/packages/bcrypt/bcrypt.wasi.cjs @@ -1,31 +1,174 @@ +// napi-rs-artifact-metadata:{"version":2,"rootEntry":"binding.js","exports":["BCRYPT_API_VERSION","DEFAULT_COST","genSalt","genSaltSync","hash","hashSync","parseOptions","verify","verifySync"],"managedRootEntries":["browser.js","binding.js","bcrypt.wasm","bcrypt.debug.wasm"]} /* eslint-disable */ -/* prettier-ignore */ - /* auto-generated by NAPI-RS */ +const __napiBindingTarget = 'wasm32-wasi' +function __napiStampBindingTarget(exportsObject, target) { + if ( + Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget') + ) { + if (exportsObject.__napiBindingTarget === target) { + // Already ours: the root entry aliases the object it loaded, so a WASI + // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that + // is a generated loader — arrives already stamped with this same value. + return target + } + const error = new Error( + '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].', + ) + error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT' + throw error + } + if (!Object.isExtensible(exportsObject)) { + // A `#[napi(module_exports)]` hook may seal or freeze this object + // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata, + // never a reason to fail an otherwise successful load, so the stamp is + // skipped. What a consumer still sees then follows the entry point: the + // browser and deferred loaders declare `__napiBindingTarget` at module + // level and go on reporting it, while the CommonJS entries hand back this + // very object as `module.exports`, so there the value is absent. + return target + } + try { + // [[Define]], not [[Set]]: an ordinary assignment walks the prototype + // chain, so an inherited accessor could swallow the value or throw and + // fail an otherwise successful load. The descriptor is what a successful + // assignment would have produced. + Object.defineProperty(exportsObject, '__napiBindingTarget', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }) + } catch { + // Same rule as the non-extensible skip above: reporting the artifact is + // metadata, never a reason to fail an otherwise successful load. An exotic + // object (a Proxy whose defineProperty trap refuses) is skipped, not + // thrown over. + } + // The CommonJS loaders assign this return value so `cjs-module-lexer` — and + // therefore Node's CJS -> ESM named export detection — can see + // `__napiBindingTarget` statically. + return target +} + const __nodeFs = require('node:fs') const __nodePath = require('node:path') const { WASI: __nodeWASI } = require('node:wasi') const { Worker } = require('node:worker_threads') const { + emnapiAsyncWorkPlugin: __emnapiAsyncWorkPlugin, + emnapiTSFNPlugin: __emnapiTSFNPlugin, createOnMessage: __wasmCreateOnMessageForFsProxy, - getDefaultContext: __emnapiGetDefaultContext, instantiateNapiModuleSync: __emnapiInstantiateNapiModuleSync, } = require('@napi-rs/wasm-runtime') +const { createContext: __emnapiCreateContext } = require('@emnapi/runtime') + +function __getWasiWorkerExecArgv() { + const __workerExecArgv = [] + for (let __index = 0; __index < process.execArgv.length; __index += 1) { + const __arg = process.execArgv[__index] + if ( + __arg === '--input-type' || + __arg === '--eval' || + __arg === '-e' || + __arg === '--print' || + __arg === '-p' + ) { + __index += 1 + continue + } + if ( + __arg.startsWith('--input-type=') || + __arg.startsWith('--eval=') || + __arg.startsWith('--print=') + ) { + continue + } + __workerExecArgv.push(__arg) + } + return __workerExecArgv +} + +function __isInvalidWasiWorkerExecArgv(errorMessage, argument) { + const __equalsIndex = argument.indexOf('=') + const __argumentName = + __equalsIndex === -1 ? argument : argument.slice(0, __equalsIndex) + return ( + errorMessage.includes(': ' + __argumentName + ',') || + errorMessage.includes(': ' + __argumentName + '=') || + errorMessage.endsWith(': ' + __argumentName) || + errorMessage.includes(', ' + __argumentName + ',') || + errorMessage.includes(', ' + __argumentName + '=') || + errorMessage.endsWith(', ' + __argumentName) + ) +} + +function __removeInvalidWasiWorkerExecArgv(execArgv, error) { + if (typeof error.message !== 'string') { + return + } + const __workerExecArgv = [] + let __removed = false + for (let __index = 0; __index < execArgv.length; __index += 1) { + const __arg = execArgv[__index] + if ( + __arg.startsWith('-') && + __isInvalidWasiWorkerExecArgv(error.message, __arg) + ) { + __removed = true + if ( + !__arg.includes('=') && + __index + 1 < execArgv.length && + !execArgv[__index + 1].startsWith('-') + ) { + __index += 1 + } + continue + } + __workerExecArgv.push(__arg) + } + return __removed ? __workerExecArgv : undefined +} + +function __createWasiWorker(filename) { + let __workerExecArgv = __getWasiWorkerExecArgv() + while (true) { + try { + return new Worker(filename, { + env: process.env, + execArgv: __workerExecArgv, + workerData: { hostRoot: __hostRoot, rootDir: __rootDir }, + }) + } catch (error) { + if (!error || error.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') { + throw error + } + const __nextWorkerExecArgv = + __removeInvalidWasiWorkerExecArgv(__workerExecArgv, error) + if (!__nextWorkerExecArgv) { + throw error + } + __workerExecArgv = __nextWorkerExecArgv + } + } +} -const __rootDir = __nodePath.parse(process.cwd()).root +const __cwd = process.cwd() +const __rootDir = __nodePath.parse(__cwd).root +const __hostRoot = + process.platform === 'android' ? __cwd : __rootDir const __wasi = new __nodeWASI({ version: 'preview1', env: process.env, preopens: { - [__rootDir]: __rootDir, - } + [__rootDir]: __hostRoot, + [__hostRoot]: __hostRoot, + }, }) -const __emnapiContext = __emnapiGetDefaultContext() - const __sharedMemory = new WebAssembly.Memory({ initial: 4000, maximum: 65536, @@ -38,80 +181,1577 @@ const __wasmDebugFilePath = __nodePath.join(__dirname, 'bcrypt.wasm32-wasi.debug if (__nodeFs.existsSync(__wasmDebugFilePath)) { __wasmFilePath = __wasmDebugFilePath } else if (!__nodeFs.existsSync(__wasmFilePath)) { + const __wasiPackageEntry = require.resolve('@node-rs/bcrypt-wasm32-wasi') + const __packagedWasmFilePath = __nodePath.join( + __nodePath.dirname(__wasiPackageEntry), + 'bcrypt.wasm32-wasi.wasm', + ) + if (!__nodeFs.existsSync(__packagedWasmFilePath)) { + throw new Error( + '@node-rs/bcrypt-wasm32-wasi is installed but is missing bcrypt.wasm32-wasi.wasm.', + ) + } + __wasmFilePath = __packagedWasmFilePath +} + +const __wasmFile = __nodeFs.readFileSync(__wasmFilePath) +let __emnapiContext + +const __wasiDisposeSymbol = Symbol.for('napi.rs.wasi.dispose') +const __wasiWorkers = new Set() +// The thread manager has to be reachable *before* anything that can throw +// during load or registration. Initialization can fail after the pool has +// already spawned workers, and the rollback still has to mark their +// terminations as expected — but `__napiModule` is assigned only when +// instantiation RETURNS, so on exactly that path it is still undefined. A +// plugin factory runs while the emnapi module is being created, before the +// wasm is loaded and before any registration function runs, and its context +// carries the very same manager instance. +let __wasiThreadManager + +function __captureWasiThreadManager(context) { + if (context && context.PThread) { + __wasiThreadManager = context.PThread + } + return {} +} + +function __getWasiThreadManager() { + const manager = + __wasiThreadManager !== undefined + ? __wasiThreadManager + : __napiModule + ? __napiModule.PThread + : undefined + if (manager && typeof manager.terminateWorker === 'function') { + return manager + } + return undefined +} +let __napiInstance +let __emnapiContextDestroyed = false +let __emnapiContextDestroyPromise +let __emnapiWasmEnvCleanupPrepared = false +let __emnapiWasmEnvCleanupPreparing = false +// The closer for a barrier that is parked between `…_begin` and `…_finish`, +// set only while that window is open. `__emnapiWasmEnvCleanupPreparing` cannot +// tell those two apart on its own: it is raised both for a purely synchronous +// frame — which must not be re-entered, and which nothing outside it can +// finish — and across this window, which spans real event-loop turns, so a +// caller that cannot yield can land in the middle of one. That caller can close +// this window, because `…_finish` is idempotent and joins, which is exactly +// what the single call does. See `__prepareWasmEnvCleanup`. +let __finishParkedWasmEnvCleanup +// Raised while a caller that can still yield is driving the barrier, so the +// queue it leaves behind is expected rather than lost. See +// `__reportUnreachedWasmEnvSettlements`. +let __emnapiWasmEnvCleanupYielding = false +let __emnapiWasmEnvSettlementLossReported = false +let __emnapiWasmEnvCleanupRan = false +let __emnapiWasmEnvCleanupDrained = false +let __emnapiWasmEnvCleanupDrainPromise +let __wasiDisposed = false +let __wasiAsyncWorkDrainPromise +let __wasiDisposePromise +let __completeWasiDisposal = function () {} +// Overridden by loader flavors that have a last-resort reclaim for a rollback +// that stopped short of destroying the context. See +// `__rollbackWasiInitialization`. +let __retainWasiRollbackForRetry = function () {} + +function __isThenable(value) { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof value.then === 'function' + ) +} + +function __createCleanupError(errors, message) { + if (errors.length === 1) { + return errors[0] + } + const __AggregateError = globalThis.AggregateError + if (typeof __AggregateError === 'function') { + return new __AggregateError(errors, message) + } + const error = new Error(message) + error.errors = errors + return error +} + +function __attachCleanupErrors(error, cleanupErrors) { + if (cleanupErrors.length === 0) { + return error + } + const cleanupError = __createCleanupError( + cleanupErrors, + 'WASI binding cleanup failed', + ) + try { + if ( + error && + (typeof error === 'object' || typeof error === 'function') + ) { + if (error.cause === undefined) { + error.cause = cleanupError + if (error.cause === cleanupError) { + return error + } + } + if (Array.isArray(error.cleanupErrors)) { + error.cleanupErrors.push(cleanupError) + return error + } else { + const attachedCleanupErrors = [cleanupError] + error.cleanupErrors = attachedCleanupErrors + if (error.cleanupErrors === attachedCleanupErrors) { + return error + } + } + } + } catch {} + const aggregate = __createCleanupError( + [error, cleanupError], + 'WASI binding initialization and cleanup failed', + ) try { - __wasmFilePath = require.resolve('@node-rs/bcrypt-wasm32-wasi/bcrypt.wasm32-wasi.wasm') + aggregate.cause = error + } catch {} + return aggregate +} + +function __wrapEmnapiContextDestroyForSettlement( + context, + prepareEnvCleanup, + isPreparingEnvCleanup, +) { + let destroy + try { + destroy = context.destroy } catch { - throw new Error('Cannot find bcrypt.wasm32-wasi.wasm file, and @node-rs/bcrypt-wasm32-wasi package is not installed.') + return context } + if (typeof destroy !== 'function') { + return context + } + try { + Object.defineProperty(context, 'destroy', { + configurable: true, + enumerable: false, + writable: true, + value: function () { + // Reentered from a promise hook that fired inside the barrier: the + // frame running it destroys as soon as it returns. + if (isPreparingEnvCleanup?.()) { + return + } + prepareEnvCleanup?.() + return Reflect.apply(destroy, this, arguments) + }, + }) + } catch {} + return context } -const { instance: __napiInstance, module: __wasiModule, napiModule: __napiModule } = __emnapiInstantiateNapiModuleSync(__nodeFs.readFileSync(__wasmFilePath), { - context: __emnapiContext, - asyncWorkPoolSize: (function() { - const threadsSizeFromEnv = Number(process.env.NAPI_RS_ASYNC_WORK_POOL_SIZE ?? process.env.UV_THREADPOOL_SIZE) - // NaN > 0 is false - if (threadsSizeFromEnv > 0) { - return threadsSizeFromEnv - } else { - return 4 - } - })(), - reuseWorker: true, - wasi: __wasi, - onCreateWorker() { - const worker = new Worker(__nodePath.join(__dirname, 'wasi-worker.mjs'), { - env: process.env, - }) - worker.onmessage = ({ data }) => { - __wasmCreateOnMessageForFsProxy(__nodeFs)(data) +function __isPreparingWasmEnvCleanup() { + return __emnapiWasmEnvCleanupPreparing +} + +function __prepareWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupPrepared) { + return + } + // A handshake parked between its two halves is one this frame can close, and + // must: every caller of this function is about to destroy the context, and + // the turns the poll is waiting for will not come — an 'exit' teardown is + // the last thing the process runs, and `Context.destroy()` takes the + // environment away. Closing it here runs `…_finish`, which is the call that joins, so + // this degrades to exactly the single call below. Leaving it open instead + // destroys the context with the barrier still raised, the runtime never + // joined and the workers never drained. + const finishParked = __finishParkedWasmEnvCleanup + if (finishParked !== undefined) { + finishParked() + __reportUnreachedWasmEnvSettlements() + return + } + if (__emnapiWasmEnvCleanupPreparing) { + return + } + const prepare = __napiInstance?.exports?.napi_prepare_wasm_env_cleanup + if (typeof prepare === 'function') { + // The addon settles the promises it cancels synchronously, under a + // non-reentrant lifecycle mutex: anything a promise hook calls from in + // here must not reach this export again. + __emnapiWasmEnvCleanupPreparing = true + try { + prepare() + } finally { + __emnapiWasmEnvCleanupPreparing = false + } + __emnapiWasmEnvCleanupRan = true + __reportUnreachedWasmEnvSettlements() + } + __emnapiWasmEnvCleanupPrepared = true +} + +/** + * Say so when the barrier leaves settlements queued and nothing is left that + * could deliver them. + * + * Only the disposal chain yields the event-loop turns @emnapi/core needs to + * dispatch its queue. Every other caller of the barrier destroys in the same + * turn — a raw `Context.destroy()`, the 'exit' teardown — and + * `Context.destroy()` runs the threadsafe function's cleanup hook, which drains + * that queue with a null env and discards it. The promises those settlements + * were for then hang forever, silently. + * + * Loud, once, and never throwing: this runs from inside `Context.destroy()`, + * emnapi's own beforeExit destroy included, where throwing would take the whole + * teardown down with it. Destroying anyway is still the right trade — the queue + * is already unreachable by then. + */ +function __reportUnreachedWasmEnvSettlements() { + if (__emnapiWasmEnvCleanupYielding || __emnapiWasmEnvSettlementLossReported) { + return + } + const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending + if (typeof pending !== 'function') { + return + } + let queued + try { + queued = pending() + } catch { + return + } + if (!queued) { + return + } + __emnapiWasmEnvSettlementLossReported = true + try { + const consoleHost = globalThis.console + if (consoleHost && typeof consoleHost.error === 'function') { + consoleHost.error( + "napi-rs: the wasm environment is being destroyed with " + + queued + + " queued promise settlement(s). Context.destroy() discards them, so those promises never settle. Dispose with binding[Symbol.for('napi.rs.wasi.dispose')]() instead: only it yields the event-loop turns the settlements need.", + ) } + } catch {} +} - // The main thread of Node.js waits for all the active handles before exiting. - // But Rust threads are never waited without `thread::join`. - // So here we hack the code of Node.js to prevent the workers from being referenced (active). - // According to https://github.com/nodejs/node/blob/19e0d472728c79d418b74bddff588bea70a403d0/lib/internal/worker.js#L415, - // a worker is consist of two handles: kPublicPort and kHandle. - { - const kPublicPort = Object.getOwnPropertySymbols(worker).find(s => - s.toString().includes("kPublicPort") - ); - if (kPublicPort) { - worker[kPublicPort].ref = () => {}; +// Mirror the primitive @emnapi/core schedules its threadsafe-function dispatch +// on, so the drain turns below interleave with that dispatch instead of racing +// ahead of it on a faster queue. +const __scheduleMacrotask = (function () { + if (typeof setImmediate === 'function') { + return function (callback) { + setImmediate(callback) + } + } + const __MessageChannel = globalThis.MessageChannel + if (typeof __MessageChannel === 'function') { + return function (callback) { + const channel = new __MessageChannel() + channel.port1.onmessage = function () { + channel.port1.onmessage = null + try { + channel.port1.close() + } catch {} + try { + channel.port2.close() + } catch {} + callback() } + channel.port2.postMessage(null) + } + } + return function (callback) { + setTimeout(callback, 0) + } +})() + +// A real, *referenced* timer, for waits that must let the whole host make +// progress between looks — the async-work drain polls the addon rather than +// interleaving with the @emnapi/core dispatch, so a zero-delay macrotask there +// would spin the loop instead of yielding it. Falls back to the macrotask +// scheduler on a host without timers. +function __scheduleTimer(callback, delay) { + const setTimer = globalThis.setTimeout + if (typeof setTimer !== 'function') { + __scheduleMacrotask(callback) + return + } + try { + setTimer(callback, delay) + } catch { + __scheduleMacrotask(callback) + } +} - const kHandle = Object.getOwnPropertySymbols(worker).find(s => - s.toString().includes("kHandle") - ); - if (kHandle) { - worker[kHandle].ref = () => {}; +// A real, referenced timer rather than a zero-delay macrotask, for the same +// reason the async-work drain uses one: this polls the addon instead of +// interleaving with the @emnapi/core dispatch, so a zero-delay turn would spin +// the loop instead of yielding it. +const __WASM_RUNTIME_WORK_POLL_INTERVAL_MS = 1 +// Arrivals it takes before the poll paces on the host's timers alone. One +// proves nothing: a timer armed before the host's timers stopped still fires. +const __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS = 2 +// How long a parked turn's own timer must already have been due before a +// backup that runs calls it dropped. Slack, not a deadline: a timer is due +// against the event loop's clock, which is read once per iteration, while +// these are `Date.now()` readings taken part-way through one, so the two +// drift apart by however long the loop has been inside the current iteration. +const __WASM_RUNTIME_WORK_POLL_STALL_MS = 50 +// How long a backup itself waits. What is left of it after the slack and one +// interval — 149 ms — has to cover the *two* poll turns that can separate a +// parked turn from the last backup armed while the host's timers still +// worked, so the ceiling on a single turn is half of it. See the invariant on +// `__armWasmRuntimePollStallBackup`. +const __WASM_RUNTIME_WORK_POLL_BACKUP_MS = 200 + +/** + * Pacing state for one runtime-work poll. + * + * Per poll, never per module: whether the host's timers arrive is not a + * property of the module. A host can lose its timers between two disposals, + * and in the deferred shape every instance shares this module — one healthy + * instance must not disarm the fallback for the next one. + */ +function __createWasmRuntimePollPace() { + return { + // Timers armed by *this* poll that have actually arrived. + arrivals: 0, + // The turn waiting on a timer alone *right now* — undefined whenever no + // turn is parked — and when that turn's own timer came due. + settleTurn: undefined, + turnTimerDueAt: 0, + } +} + +/** + * The backup that ends a turn whose timer is never going to arrive. + * + * Once the poll paces on the timer alone it has nothing left to fall back on + * if the host's timers stop mid-poll: the turn that armed the dead timer is + * the turn that parks, and a parked poll schedules nothing that could notice. + * So every turn arms one of these before it yields, and each one compares due + * times instead of measuring how long the parked turn has been waiting. + * + * Invariant: a parked turn is ended by the newest backup that was armed while + * the host's timers still worked, and a backup ends a turn only when that + * turn's own timer was already due a whole window before the backup itself. + * Neither half turns on how far apart the arms happen to fall — what bounds + * the rescue is how far back that newest live backup is: + * + * - *Ends it.* Hosts run timers in due order, so a backup that runs while a + * turn due a whole window earlier is still parked proves that turn's timer + * was dropped rather than merely late. That same comparison is what leaves a + * healthy host alone: there the turn's timer has already run and cleared + * `settleTurn` before any backup due after it can look. + * - *Two turns back, not one.* A turn that ended does not prove its own timer + * arrived: until `…_TRUSTED_ARRIVALS` is reached every turn arms both + * primitives and the macrotask wins, so such a turn can end with its own + * timer — and the backup armed one line before it — already dead. The + * arrival that then flips the poll onto the timer alone can itself be a + * timer armed before the host's timers died. So the turn that parks can sit + * two turns past the last live arm, and the newest live backup is due + * `…_BACKUP_MS` less *two* turn lengths after that turn's own timer. + * Arming on every turn is what holds it to two, rather than however far back + * a throttle last let one through. + * - *Ceiling.* Coverage therefore holds while two consecutive poll turns fit + * inside `…_BACKUP_MS` less the slack and one interval: 149 ms, so 74 ms + * per turn (measured: a 74 ms turn is still rescued, a 75 ms one parks). + * Past that the turn stays parked and the disposal promise never settles. + * The bound is deliberate: reaching it takes a host that drops timers + * mid-poll *and* keeps every poll turn busy for more than 74 ms, and neither + * Node nor WebContainer — the hosts that run the threaded artifact — does + * the second. + * + * The poll then goes back to arming both primitives until two fresh arrivals + * prove the timers again. A host that stops running the timers it has + * *already* accepted leaves nothing to fire, and the disposal promise stays + * pending rather than wedging the thread — the same outcome as a blocking + * closure that never returns. Unreferenced wherever the host allows it: the + * poll's own turn timers are what keep the loop alive, never these. + */ +function __armWasmRuntimePollStallBackup(pace) { + const setTimer = globalThis.setTimeout + if (typeof setTimer !== 'function') { + // Nothing to back up: `__scheduleTimer` is on the macrotask channel + // already, and that one cannot park. + return + } + // Read before arming, so this never claims to be due earlier than the timer + // actually is: a backup ends a turn only when it is provably due after it. + const dueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_BACKUP_MS + let handle + try { + handle = setTimer(() => { + const settleTurn = pace.settleTurn + if ( + !settleTurn || + pace.turnTimerDueAt > dueAt - __WASM_RUNTIME_WORK_POLL_STALL_MS + ) { + // No turn is parked, or the parked one's timer came due too close to + // this backup to call it dropped — it may still arrive, and the turn + // that armed it armed a backup due a whole window after *that*. + return } + pace.arrivals = 0 + pace.settleTurn = undefined + settleTurn() + }, __WASM_RUNTIME_WORK_POLL_BACKUP_MS) + } catch { + return + } + if (handle && typeof handle.unref === 'function') { + try { + handle.unref() + } catch {} + } +} - worker.unref(); +/** + * One turn of the runtime-work poll. + * + * `__scheduleTimer` falls back to the macrotask scheduler when `setTimeout` is + * missing or throws, but not when it is present, returns a handle and never + * fires — fake timers in a test suite that disposes from an `afterEach`, or a + * host whose timers belong to an IO context that is already gone. That host + * would park this poll forever, and the poll is unbounded, so nothing would + * ever call `…_finish`. + * + * Arm both primitives until timers armed by this poll have arrived twice, and + * let whichever lands first end the turn; the loser resolves nothing. A host + * with working timers therefore pays the double arming for the first turn or + * two — the macrotask wins the race, but the timers behind it still arrive and + * are counted — and paces on the timer alone from then on, instead of spinning + * the loop on a zero-delay queue. A host whose timers never arrive keeps both, + * and the macrotask is what keeps the poll moving. A host whose timers stop + * after proving themselves is caught by `__armWasmRuntimePollStallBackup`, + * which ends the parked turn and puts this poll back on both. + */ +function __yieldWasmRuntimePollTurn(pace) { + // Armed before the turn yields, and by every turn: what rescues a parked + // turn has to have been armed while the host's timers still worked, and the + // turn that parks is the one whose own timer is already dead. + __armWasmRuntimePollStallBackup(pace) + return new Promise((resolve) => { + let settled = false + const settle = () => { + if (settled) { + return + } + settled = true + if (pace.settleTurn === settle) { + // Nothing is parked any more: a backup running later must not read a + // due time this turn has already answered. + pace.settleTurn = undefined + } + resolve() } - return worker - }, - overwriteImports(importObject) { - importObject.env = { - ...importObject.env, - ...importObject.napi, - ...importObject.emnapi, - memory: __sharedMemory, - } - return importObject - }, - beforeInit({ instance }) { - for (const name of Object.keys(instance.exports)) { - if (name.startsWith('__napi_register__')) { - instance.exports[name]() + __scheduleTimer(() => { + pace.arrivals++ + settle() + }, __WASM_RUNTIME_WORK_POLL_INTERVAL_MS) + // Read next to the arming it describes; see + // `__armWasmRuntimePollStallBackup` for what the two due times mean. + const turnTimerDueAt = Date.now() + __WASM_RUNTIME_WORK_POLL_INTERVAL_MS + if (pace.arrivals < __WASM_RUNTIME_WORK_POLL_TRUSTED_ARRIVALS) { + __scheduleMacrotask(settle) + return + } + // Paced by the timer alone from here; the backup is what ends this turn if + // the timer never arrives. + pace.settleTurn = settle + pace.turnTimerDueAt = turnTimerDueAt + }) +} + +/** + * The barrier for callers that can yield: `__prepareWasmEnvCleanup` with real + * event-loop turns in the middle. + * + * `napi_prepare_wasm_env_cleanup` waits — it returns only once the addon's + * async runtime has quiesced, and on `wasm32-wasip1-threads` the thread it + * waits on is this one, the only thread that can give a running blocking + * closure the JavaScript turn *it* is waiting for. A single call there can wait + * for work that can never finish. The addon's two-phase form splits that: + * `…_begin` stops the runtime without joining and reports whether anything is + * still live, `napi_wasm_runtime_work_pending` answers that question again + * without blocking, and `…_finish` joins. The turns yielded in between are the + * entire point. + * + * The poll has no deadline, for the same reason the async-work drain below has + * none: giving up means calling `…_finish`, which joins on this thread, and the + * work it would join is the work that is waiting for a turn from this thread — + * so a bound does not end the wait, it only moves it somewhere the JavaScript + * thread can no longer be reached. A blocking closure that never returns keeps + * the disposal promise pending instead, exactly as a task whose `execute` never + * returns already keeps an *undisposed* process alive. The host contract is in + * `crates/async-runtime/README.md`: a blocking closure must never wait on a + * JavaScript turn. The process-exit path still blocks in `…_finish`, because it + * has no turns left to give (see `__prepareWasmEnvCleanup`). + * + * Feature-detected like every other export in this teardown, so an addon built + * against a napi crate that predates the split keeps the single blocking call. + * Returns nothing whenever the handshake finished without yielding, which keeps + * an idle disposal synchronous. + */ +function __prepareWasmEnvCleanupWithTurns() { + if (__emnapiWasmEnvCleanupPrepared || __emnapiWasmEnvCleanupPreparing) { + return + } + const exports = __napiInstance?.exports + const begin = exports?.napi_prepare_wasm_env_cleanup_begin + const finish = exports?.napi_prepare_wasm_env_cleanup_finish + if (typeof begin !== 'function' || typeof finish !== 'function') { + // No split to use. The settlement drain still follows this, so the queue + // the single call leaves behind is expected rather than lost. + __emnapiWasmEnvCleanupYielding = true + try { + __prepareWasmEnvCleanup() + } finally { + __emnapiWasmEnvCleanupYielding = false + } + return + } + const workPending = exports?.napi_wasm_runtime_work_pending + // The in-flight flag stays raised across the turns below, so a `destroy()` + // from one of the JavaScript handlers they run is the same no-op it is inside + // the single call: the barrier is up and the runtime is mid-teardown, and + // destroying between the halves would strand exactly what this delivers. + __emnapiWasmEnvCleanupPreparing = true + let live + try { + live = begin() + } catch (error) { + __emnapiWasmEnvCleanupPreparing = false + throw error + } + __emnapiWasmEnvCleanupRan = true + const finishCleanup = () => { + if (__emnapiWasmEnvCleanupPrepared) { + // Already closed by a caller that could not yield — the 'exit' teardown + // reached `__prepareWasmEnvCleanup` while this poll was parked. `…_finish` + // is idempotent, but the flags it lowers are not: running it again here + // would clear a `preparing` some later barrier had raised. + return + } + __finishParkedWasmEnvCleanup = undefined + try { + finish() + } finally { + __emnapiWasmEnvCleanupPreparing = false + } + __emnapiWasmEnvCleanupPrepared = true + } + if (!live || typeof workPending !== 'function') { + finishCleanup() + return + } + // Publish the closer before yielding: from here until `finishCleanup` runs, + // a caller that cannot yield is entitled to end this handshake itself. + __finishParkedWasmEnvCleanup = finishCleanup + return (async () => { + // Unbounded, exactly like the async-work drain below. The wait ends when + // the addon reports its runtime work finished; the turns spent here are + // what let that happen at all. + const pace = __createWasmRuntimePollPace() + for (;;) { + await __yieldWasmRuntimePollTurn(pace) + try { + if (!workPending()) { + return + } + } catch { + // A trap is the only way this fails, and a trapped instance has no + // reachable work left. Stop polling and finish. + return } } - }, -}) + })().then(finishCleanup, finishCleanup) +} + +// Turns to wait for while the addon still reports queued settlements. Reaching +// zero is the only success. A counter still nonzero at this bound rejects the +// disposal as retryable (`ERR_NAPI_WASI_CLEANUP_PENDING`) rather than +// destroying the context over a still-queued settlement — the wait stays +// bounded either way. +const __WASM_ENV_CLEANUP_DRAIN_TURNS = 128 +// Without `napi_wasm_env_cleanup_pending` the queue is not observable. Fall +// back to the number of turns @emnapi/core needs to coalesce and dispatch a +// call made on this thread (two), plus a margin. +const __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS = 4 + +/** + * `napi_prepare_wasm_env_cleanup` only *queues* the promise settlements of the + * tasks it cancelled: `napi_call_threadsafe_function` appends to the + * threadsafe-function queue, and @emnapi/core dispatches that queue from a + * macrotask — two coalescing turns later, even for a call made on this very + * thread. `Context.destroy()` then runs the threadsafe function's cleanup hook, + * which drains the queue with a null env and *discards* whatever is still in it. + * + * So destroying without yielding first strands exactly the promises the barrier + * exists to settle. Yield real event-loop turns until the addon reports the + * queue empty; microtask checkpoints cannot help, no number of them lets a + * macrotask run. + * + * Returns nothing when there is nothing to wait for, which keeps disposal + * synchronous in the common case. + * + * The "already drained" flag is set only once a wait has actually finished. + * Scheduling a macrotask can fail — a host-provided or patched `setImmediate` + * that throws is enough — and a disposal that rejects stays retryable, so + * marking the drain complete up front would make the retry skip it and destroy + * the context with the barrier's settlements still queued. + * + * A wait that runs out of turns with the counter still nonzero rejects with + * `ERR_NAPI_WASI_CLEANUP_PENDING` for the same reason: at that point + * "finished" is indistinguishable from the stranding above, and destroying + * would discard the very settlement the wait was for. The rejection leaves the + * flag unset and disposal retryable. + */ +function __drainWasmEnvCleanup() { + if (__emnapiWasmEnvCleanupDrained || !__emnapiWasmEnvCleanupRan) { + return + } + if (__emnapiWasmEnvCleanupDrainPromise) { + return __emnapiWasmEnvCleanupDrainPromise + } + const pending = __napiInstance?.exports?.napi_wasm_env_cleanup_pending + const observable = typeof pending === 'function' + if (observable) { + let queued + try { + queued = pending() + } catch { + __emnapiWasmEnvCleanupDrained = true + return + } + if (!queued) { + __emnapiWasmEnvCleanupDrained = true + return + } + } + const limit = observable + ? __WASM_ENV_CLEANUP_DRAIN_TURNS + : __WASM_ENV_CLEANUP_BLIND_DRAIN_TURNS + const drainPromise = (async () => { + let queued = 0 + for (let turn = 0; turn < limit; turn++) { + await new Promise((resolve) => { + __scheduleMacrotask(resolve) + }) + if (!observable) { + continue + } + try { + queued = pending() + } catch { + return + } + if (!queued) { + return + } + } + if (!observable) { + // Blind wait: without `napi_wasm_env_cleanup_pending` the bound IS the + // contract — there is nothing to consult, so finishing the turns is + // finishing the drain. + return + } + // The counter is still nonzero after every turn the bound allows. The wait + // stays bounded — but claiming success here would be indistinguishable from + // the stranding this drain exists to prevent: disposal would go on to + // destroy the context, whose cleanup hook discards the still-queued + // settlement with a null env, and the promise it was for hangs forever. + // Reject instead, as a retryable cleanup failure: the drained flag stays + // unset, dispose() (and the rollback) decline to destroy, and a later + // dispose() runs the drain again — by which time the queue has usually been + // delivered. A counter that is somehow stuck nonzero therefore costs each + // attempt at most another bounded wait and a rejection, never a stranded + // promise; the process-exit teardown still reclaims the context. + const drainError = new Error( + 'the wasm environment still reports ' + + queued + + ' queued settlement(s) after ' + + limit + + ' event-loop turns; the context was not destroyed - retry dispose() to wait for the queue again', + ) + drainError.code = 'ERR_NAPI_WASI_CLEANUP_PENDING' + throw drainError + })().then( + (value) => { + // Set only when the wait actually finished AND the queue was seen empty + // (or is unobservable): a drain that timed out with settlements still + // queued rejects above and must stay repeatable. + __emnapiWasmEnvCleanupDrained = true + __emnapiWasmEnvCleanupDrainPromise = undefined + return value + }, + (error) => { + __emnapiWasmEnvCleanupDrainPromise = undefined + throw error + }, + ) + __emnapiWasmEnvCleanupDrainPromise = drainPromise + return drainPromise +} + +function __destroyEmnapiContext() { + if (__emnapiContextDestroyed || __emnapiContext === undefined) { + __emnapiContextDestroyed = true + return + } + if (__emnapiContextDestroyPromise) { + return __emnapiContextDestroyPromise + } + + __prepareWasmEnvCleanup() + if (__isPreparingWasmEnvCleanup()) { + // Reached from inside the synchronous barrier — a promise hook one of the + // settlements above ran, which is the reentrancy the destroy wrapper + // exists for. `Context.destroy()` below would hit that wrapper's in-flight + // no-op and answer `undefined`, and recording that as a completed destroy + // is what makes the frame that *did* start the barrier skip the real one + // afterwards, leaving the context retained with its cleanup hooks unrun. + // Refuse instead: nothing is flagged, and that frame destroys for real the + // moment it returns. The deferred loader carries the same backstop. A + // parked handshake cannot get here — `__prepareWasmEnvCleanup` closes one + // rather than skipping it. + return + } + const result = __emnapiContext.destroy() + if (!__isThenable(result)) { + __emnapiContextDestroyed = true + return + } + + const destroyPromise = Promise.resolve(result).then( + (value) => { + __emnapiContextDestroyed = true + return value + }, + (error) => { + __emnapiContextDestroyPromise = undefined + throw error + }, + ) + __emnapiContextDestroyPromise = destroyPromise + return destroyPromise +} + +/** + * Holds the event loop open until `work` settles. + * + * Nothing else can: the pool workers are deliberately unreferenced so an idle + * binding cannot keep a process alive, and referencing them again for the + * termination does not hold either — emnapi unreferences a worker the moment it + * reports `async-thread-ready`, which for a worker that was still starting + * lands *after* the termination began. Without a handle of its own, an + * `await dispose()` with nothing else pending exits the process with its + * promise unsettled, and everything after the `await` is skipped. + * + * The timer is cleared as soon as the work settles, so this never outlives the + * disposal that asked for it. + */ +function __keepEventLoopAliveUntil(work) { + const setTimer = globalThis.setInterval + const clearTimer = globalThis.clearInterval + if (typeof setTimer !== 'function' || typeof clearTimer !== 'function') { + return work + } + let timer + try { + timer = setTimer(function () {}, 50) + } catch { + return work + } + const release = function () { + try { + clearTimer(timer) + } catch {} + } + return work.then( + (value) => { + release() + return value + }, + (error) => { + release() + throw error + }, + ) +} + +// How often to re-read `napi_wasm_async_work_pending` while waiting. The wait +// ends when the addon reports zero, so this only decides how promptly disposal +// notices — not how long it waits. +const __WASI_ASYNC_WORK_POLL_INTERVAL_MS = 1 + +/** + * Settles this addon's outstanding `napi_async_work` before the teardown that + * would strand it. + * + * `napi_prepare_wasm_env_cleanup` does not cover async work, and nothing about + * it is observable from JavaScript: the threadless archive resolves + * `napi_*_async_work` through the `@emnapi/core` plugins, but the threaded one + * links the C `async_work.c` on the uv threadpool, so there the wasm neither + * imports nor exports those symbols and the only brackets a loader could watch + * (`_emnapi_ctx_*_waiting_request_counter`) are shared with threadsafe + * functions. The addon is the one place both flavors go through, so it answers + * for both, through the same kind of handshake the settlement drain uses: + * + * - `napi_wasm_cancel_pending_async_work()` cancels what no thread has + * started. Those completion callbacks run with `napi_cancelled`, which + * napi-rs turns into a promise rejected with an `AbortError`. + * - `napi_wasm_async_work_pending()` counts what is still owed a completion + * callback. Work already executing refuses cancellation and stays counted + * until it finishes normally — which it can, because this runs before the + * barrier, before `Context.destroy()` and before anything is terminated. + * + * Both exports are optional: an addon built against a napi crate that predates + * them drains nothing and keeps the previous behavior, exactly as the + * `napi_wasm_env_cleanup_pending` handshake degrades. + * + * Returns nothing when there is nothing outstanding, which keeps disposal + * synchronous in the common case. The promise it returns otherwise never + * rejects. + * + * The wait has no deadline, and that is the point: giving up would destroy the + * environment with a completion callback still owed, which is the stranding + * this exists to prevent. A task whose `execute` never returns already keeps an + * *undisposed* process alive in exactly the same way, so disposal inherits that + * rather than inventing a bound it cannot honor. + * + * Safe to call from inside a completion callback, which is reachable: settling + * a task runs addon code that can re-enter JavaScript — a setter on the value + * being handed back, a threadsafe-function callback — and that JavaScript can + * call `dispose()`. Two things make it terminate rather than wait on itself: + * + * - The addon keeps a work registered until its completion callback + * *finishes*, so the count read here is at least one and this takes the + * polling path instead of declaring the environment drained and tearing it + * down from inside the frame that is still settling a promise. + * - The poll is a timer, so it cannot run until the callback has returned to + * the host — by which time that work has left the registry. The count the + * next poll reads is the one taken after the callback finished. + * + * `__disposeWasiBinding` hands every caller the same in-flight promise, so the + * nested call joins this disposal rather than starting a second one. + */ +function __drainWasiAsyncWork() { + if (__wasiAsyncWorkDrainPromise !== undefined) { + return __wasiAsyncWorkDrainPromise + } + const exports = __napiInstance?.exports + const pending = exports?.napi_wasm_async_work_pending + const cancelPending = exports?.napi_wasm_cancel_pending_async_work + if (typeof pending !== 'function' || typeof cancelPending !== 'function') { + return + } + + const readPending = () => { + try { + return pending() + } catch (error) { + // A trap is the only way this call fails: it reads a counter and cannot + // allocate or call back into JavaScript. A trapped instance can no longer + // run anything, so its outstanding work is unreachable by definition — + // there is nothing left to wait for, and refusing to dispose would only + // keep a dead instance and its stuck counter alive. Best-effort here is + // the honest answer, and it is what disposal did before this drain + // existed. + // + // Only a trap. Anything else means the export is not what this loader + // thinks it is, which is a defect worth surfacing rather than disposing + // over. + if (error instanceof globalThis.WebAssembly.RuntimeError) { + return 0 + } + throw error + } + } + + if (!readPending()) { + return + } + try { + cancelPending() + } catch { + // Cancellation is an optimization: it bounds the wait by the work already + // executing. Failing it only means waiting for the whole queue instead. + } + if (!readPending()) { + return + } + + const drainPromise = __keepEventLoopAliveUntil( + (async () => { + while (readPending()) { + await new Promise((resolve) => { + __scheduleTimer(resolve, __WASI_ASYNC_WORK_POLL_INTERVAL_MS) + }) + } + })(), + ).then( + () => { + __wasiAsyncWorkDrainPromise = undefined + }, + (error) => { + // A wait that could not run is not a wait that finished. The only way + // here is a host whose timers and macrotask primitives all refuse, and + // the work is still outstanding — reporting success would destroy the + // environment over it, which is the stranding this exists to prevent. + // Reject instead: disposal stays retryable, and the context is not + // destroyed. Clearing the memo first is what makes the retry re-run this. + __wasiAsyncWorkDrainPromise = undefined + throw error + }, + ) + __wasiAsyncWorkDrainPromise = drainPromise + return drainPromise +} + +/** + * `@emnapi/wasi-threads` counts a worker exit as expected only when its own + * thread manager performed the termination. A bare `worker.terminate()` reaches + * the manager's `exit` listener instead, which reports + * `worker (tid = N) sent an error! ... stopped with exit code 1` and rethrows + * inside the emit — aborting the `once('exit')` that backs the terminate + * promise, so disposal never settles and the process dies with an uncaught + * exception. Mark the termination through the manager first. + * + * The manager comes from `__getWasiThreadManager`, not from `__napiModule`: + * the initialization rollback runs on the one path where instantiation never + * returned, so `__napiModule` is still undefined there while the workers it + * spawned are already registered and loaded. + * + * Not `terminateAllThreads()`: that one recreates the pool it just shut down. + */ +function __terminateWasiWorkers() { + const cleanupErrors = [] + const pending = [] + const threadManager = __getWasiThreadManager() + + for (const worker of __wasiWorkers) { + let result + try { + if (threadManager) { + threadManager.terminateWorker(worker) + // `terminateWorker` leaves behind a reporter that logs every message + // still queued on the port, which Node flushes on exit. Nothing is + // listening for those any more. + worker.onmessage = undefined + } + result = worker.terminate() + } catch (error) { + cleanupErrors.push(error) + continue + } + if (__isThenable(result)) { + pending.push( + Promise.resolve(result).then( + () => { + __wasiWorkers.delete(worker) + }, + (error) => { + cleanupErrors.push(error) + }, + ), + ) + } else { + __wasiWorkers.delete(worker) + } + } + + const finish = () => { + if (cleanupErrors.length > 0) { + throw __createCleanupError( + cleanupErrors, + 'Failed to terminate WASI workers', + ) + } + } + return pending.length > 0 + ? __keepEventLoopAliveUntil(Promise.all(pending)).then(finish) + : finish() +} + +function __finishWasiDisposal() { + const workerResult = __terminateWasiWorkers() + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult).then(__completeWasiDisposal) + } + return __completeWasiDisposal() +} + +function __continueWasiDisposal() { + const destroyResult = __destroyEmnapiContext() + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult).then(__finishWasiDisposal) + } + return __finishWasiDisposal() +} + +function __drainWasmEnvForWasiDisposal() { + const drainResult = __drainWasmEnvCleanup() + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then(__continueWasiDisposal) + } + return __continueWasiDisposal() +} + +function __cleanUpWasmEnvForWasiDisposal() { + // Run the pre-teardown barrier — yielding the turns its two-phase form asks + // for, when the addon has one — then let the settlements it queued actually + // reach JavaScript, and only then destroy the environment. Doing any two of + // these back to back is what strands them. + const prepareResult = __prepareWasmEnvCleanupWithTurns() + if (__isThenable(prepareResult)) { + return Promise.resolve(prepareResult).then(__drainWasmEnvForWasiDisposal) + } + return __drainWasmEnvForWasiDisposal() +} + +function __startWasiDisposal() { + // Outstanding `napi_async_work` goes first, while the environment is still + // completely live: the completion callbacks run addon code, and everything + // after this point takes that away from them — the barrier shuts the async + // runtime down, `Context.destroy()` stops JavaScript calls, and terminating + // the pool threads removes what would have reported the work finished. + const asyncWorkResult = __drainWasiAsyncWork() + if (__isThenable(asyncWorkResult)) { + return Promise.resolve(asyncWorkResult).then( + __cleanUpWasmEnvForWasiDisposal, + ) + } + return __cleanUpWasmEnvForWasiDisposal() +} + +/** + * Disposes this generated WASI binding. + * + * Access this function with: + * binding[Symbol.for('napi.rs.wasi.dispose')]() + */ +function __disposeWasiBinding() { + if (__wasiDisposePromise) { + return __wasiDisposePromise + } + if (__wasiDisposed) { + return Promise.resolve() + } + + let resolveDispose + let rejectDispose + const disposePromise = new Promise((resolve, reject) => { + resolveDispose = resolve + rejectDispose = reject + }) + __wasiDisposePromise = disposePromise + + let result + try { + result = __startWasiDisposal() + } catch (error) { + __wasiDisposePromise = undefined + rejectDispose(error) + return disposePromise + } + + Promise.resolve(result).then( + (value) => { + __wasiDisposed = true + resolveDispose(value) + }, + (error) => { + __wasiDisposePromise = undefined + rejectDispose(error) + }, + ) + return disposePromise +} + +function __publishWasiDispose(exports) { + Object.defineProperty(exports, __wasiDisposeSymbol, { + configurable: false, + enumerable: false, + value: __disposeWasiBinding, + writable: false, + }) +} + +function __finishWasiInitializationRollback(cleanupErrors) { + let workerResult + try { + workerResult = __terminateWasiWorkers() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return cleanupErrors + } + if (__isThenable(workerResult)) { + return Promise.resolve(workerResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => cleanupErrors) + } + return cleanupErrors +} + +function __destroyContextForWasiRollback(cleanupErrors) { + let destroyResult + try { + destroyResult = __destroyEmnapiContext() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __finishWasiInitializationRollback(cleanupErrors) + } + if (__isThenable(destroyResult)) { + return Promise.resolve(destroyResult) + .catch((cleanupError) => { + cleanupErrors.push(cleanupError) + }) + .then(() => __finishWasiInitializationRollback(cleanupErrors)) + } + return __finishWasiInitializationRollback(cleanupErrors) +} + +/** + * Leaves a rollback that could not reach the queued settlements undestroyed, and + * hands it to whatever this flavor has that can still reclaim it. + */ +function __retainFailedWasiRollback(cleanupErrors) { + try { + __retainWasiRollbackForRetry() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + return cleanupErrors +} + +/** + * Initialization can fail *after* registration has already run, and registration + * runs with a live environment: a module-init hook can start async work and then + * return an error, and the promise it created may already have escaped into + * JavaScript. The barrier cancels that work and *queues* the settlement, so this + * path needs the same drain the ordinary disposal does — destroying without + * yielding discards the queue with a null env and strands the promise. + * + * Stays synchronous when nothing is queued, which covers every failure before + * `beforeInit`: there is no instance to run the barrier on, so nothing to drain. + * + * A barrier or drain that did *not* finish stops the rollback short of + * destroying, which is what `dispose()` already does — a rejected drain there + * never reaches `__continueWasiDisposal`. Destroying anyway is the worse of the + * two trades, and not because of what it saves: + * + * - It cannot deliver the settlements. `Context.destroy()` runs the + * threadsafe function's cleanup hook, which drains the queue with a null env + * and discards it, so a promise that already escaped into JavaScript hangs + * forever with nothing left that could ever settle it. + * - It saves less than it looks. `Context.destroy()` stops JavaScript calls + * and runs cleanup hooks; it does not free the wasm instance or its Memory, + * which this module's scope holds either way. What stopping short retains is + * the emnapi context's bookkeeping and its un-run cleanup hooks. + * - Retry is not theoretical. A rollback that records a cleanup error is + * already kept in the process-wide registry above, so re-`require()`ing this + * file replays it instead of re-instantiating — and the `6e15de6f` flag fix + * means the replay drains again rather than skipping it. Destroying first is + * what makes that retained record useless. + * + * The residual cost is honest: the CJS flavor hands the context to its + * `process.on('exit')` teardown, so a process that never retries still reclaims + * it on the way out. The ESM browser flavor has no equivalent — a module that + * throws while evaluating is permanently errored, so re-importing rethrows + * without re-running this file — and there the context stays until the realm + * goes away. That is the deliberate choice: a hung promise is a silent liveness + * bug with no upper bound, while the retained bookkeeping is bounded by the page. + */ +function __rollbackWasiInitialization() { + // The environment teardown this rollback performs, kept nested so it cannot + // be reached without the async-work drain below running first. + function __rollbackWasmEnvForWasiInitialization() { + const cleanupErrors = [] + let prepareResult + try { + prepareResult = __prepareWasmEnvCleanupWithTurns() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + } + if (__isThenable(prepareResult)) { + return Promise.resolve(prepareResult).then( + () => __drainWasmEnvForWasiRollback(cleanupErrors), + (cleanupError) => { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + }, + ) + } + return __drainWasmEnvForWasiRollback(cleanupErrors) + } + + // The settlement drain of the rollback above, reached either straight away or + // after the barrier's two-phase form has yielded its turns. A barrier that + // did not finish never gets here: it retains instead, exactly as a drain that + // did not finish does. + function __drainWasmEnvForWasiRollback(cleanupErrors) { + let drainResult + try { + drainResult = __drainWasmEnvCleanup() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + } + if (__isThenable(drainResult)) { + return Promise.resolve(drainResult).then( + () => __destroyContextForWasiRollback(cleanupErrors), + (cleanupError) => { + cleanupErrors.push(cleanupError) + return __retainFailedWasiRollback(cleanupErrors) + }, + ) + } + return __destroyContextForWasiRollback(cleanupErrors) + } + + // Same reason as `__startWasiDisposal`: a module-init hook can start async + // work before the load goes on to fail, and this rollback tears down exactly + // what those completions need. Settle them while everything is still live, + // before the barrier and the teardown above take that away. + // + // A drain that could not finish leaves async work possibly outstanding, and + // destroying the context over it would strand exactly what this rollback is + // there to settle. Stop short and retain instead — the same trade + // `__rollbackWasmEnvForWasiInitialization` makes for the settlement drain, so + // the context stays reclaimable by a retry or by this flavor's own + // last-resort teardown. + const __retainAfterAsyncWorkDrainFailure = (cleanupError) => + __retainFailedWasiRollback([cleanupError]) + let asyncWorkResult + try { + asyncWorkResult = __drainWasiAsyncWork() + } catch (cleanupError) { + return __retainAfterAsyncWorkDrainFailure(cleanupError) + } + if (__isThenable(asyncWorkResult)) { + return Promise.resolve(asyncWorkResult).then( + __rollbackWasmEnvForWasiInitialization, + __retainAfterAsyncWorkDrainFailure, + ) + } + return __rollbackWasmEnvForWasiInitialization() +} + +const __wasiRollbackRegistrySymbol = Symbol.for('napi.rs.wasi.rollback.registry.v1') +const __wasiRollbackRegistryKey = + typeof __filename === 'string' ? __filename : __wasmFilePath + +function __getWasiRollbackRegistry() { + const existing = process[__wasiRollbackRegistrySymbol] + if (existing !== undefined) { + if (!(existing instanceof Map)) { + throw new TypeError( + 'The process-wide NAPI-RS WASI rollback registry is invalid', + ) + } + return existing + } + const registry = new Map() + Object.defineProperty(process, __wasiRollbackRegistrySymbol, { + configurable: false, + enumerable: false, + value: registry, + writable: false, + }) + return registry +} + +const __wasiRollbackRegistry = __getWasiRollbackRegistry() + +function __completeWasiInitializationRollback(record, cleanupErrors) { + try { + if (cleanupErrors.length === 0) { + if ( + __wasiRollbackRegistry.get(__wasiRollbackRegistryKey) === record + ) { + __wasiRollbackRegistry.delete(__wasiRollbackRegistryKey) + } + return + } + record.error = __attachCleanupErrors(record.error, cleanupErrors) + } catch (cleanupError) { + try { + record.error = __createCleanupError( + [record.error, cleanupError], + 'WASI binding initialization and cleanup failed', + ) + } catch {} + } finally { + record.active = false + record.promise = undefined + } +} + +function __runWasiInitializationRollback(record) { + if (record.active) { + return + } + record.active = true + + let rollbackResult + try { + rollbackResult = record.rollback() + } catch (cleanupError) { + __completeWasiInitializationRollback(record, [cleanupError]) + return + } + + if (!__isThenable(rollbackResult)) { + __completeWasiInitializationRollback(record, rollbackResult) + return + } + + record.promise = Promise.resolve(rollbackResult).then( + (cleanupErrors) => { + __completeWasiInitializationRollback(record, cleanupErrors) + }, + (cleanupError) => { + __completeWasiInitializationRollback(record, [cleanupError]) + }, + ) +} + +const __pendingWasiRollback = __wasiRollbackRegistry.get( + __wasiRollbackRegistryKey, +) +if (__pendingWasiRollback !== undefined) { + __runWasiInitializationRollback(__pendingWasiRollback) + throw __pendingWasiRollback.error +} + +let __wasiModule +let __napiModule +let __wasiExitListenerRegistered = false + +function __removeWasiExitListener() { + if ( + __wasiExitListenerRegistered && + typeof process.removeListener === 'function' + ) { + process.removeListener('exit', __disposeWasiBindingAtExit) + } + __wasiExitListenerRegistered = false +} + +function __disposeWasiBindingAtExit() { + __wasiExitListenerRegistered = false + // An 'exit' handler cannot yield, so it cannot wait for queued promise + // settlements the way __startWasiDisposal does — the process is leaving and + // those promises have no observer left anyway. Run the synchronous teardown + // directly. Every step is idempotent, which also makes this the synchronous + // finish for a disposal that is still waiting for its drain — and, through + // __prepareWasmEnvCleanup, for one still parked between the two halves of + // the environment cleanup barrier: there are no turns left to poll with, so + // this closes that handshake with `…_finish`, which joins. + try { + __destroyEmnapiContext() + } catch {} + try { + const workerResult = __terminateWasiWorkers() + if (__isThenable(workerResult)) { + void Promise.resolve(workerResult).catch(() => {}) + } + } catch {} +} + +function __registerWasiExitListener() { + if ( + !__wasiExitListenerRegistered && + typeof process.once === 'function' + ) { + process.once('exit', __disposeWasiBindingAtExit) + __wasiExitListenerRegistered = true + } +} + +__completeWasiDisposal = __removeWasiExitListener +// A rollback that could not reach the queued settlements keeps the context so +// the registry replay above can retry it. Nothing forces that replay to happen, +// so hand the context to the same synchronous teardown a successful load uses: +// a process that exits without ever retrying still runs the cleanup hooks. The +// handler cannot yield, so it does not settle anything — but by then the process +// is leaving and those promises have no observer left anyway. +__retainWasiRollbackForRetry = __registerWasiExitListener + +function __captureEmnapiAutoDestroyListener() { + if ( + typeof process.prependListener !== 'function' || + typeof process.removeListener !== 'function' + ) { + return + } + let __autoDestroyListener + const __captureListener = (__event, __listener) => { + if (__event === 'beforeExit' && __autoDestroyListener === undefined) { + __autoDestroyListener = __listener + } + } + try { + // Run before existing newListener hooks so a hook that registers its own + // beforeExit listener cannot be mistaken for emnapi's registration. + process.prependListener('newListener', __captureListener) + } catch { + return + } + return () => { + try { + process.removeListener('newListener', __captureListener) + } catch {} + if (__autoDestroyListener !== undefined) { + try { + process.removeListener('beforeExit', __autoDestroyListener) + } catch {} + } + } +} + +try { + const __finishAutoDestroyCapture = __captureEmnapiAutoDestroyListener() + try { + __emnapiContext = __wrapEmnapiContextDestroyForSettlement( + __emnapiCreateContext({ autoDestroy: false }), + __prepareWasmEnvCleanup, + __isPreparingWasmEnvCleanup, + ) + // emnapi 2.x still registers an unconditional once-listener for + // beforeExit that auto-destroys the context, and suppressDestroy() only + // neutralizes its callback without removing it. This loader owns cleanup + // through its 'exit' listener, so emnapi's listener is captured and + // removed; suppressDestroy() remains the safety net when removal fails. + __emnapiContext.suppressDestroy() + } finally { + // Remove only the exact emnapi callback captured above. + __finishAutoDestroyCapture?.() + } + + ;({ + instance: __napiInstance, + module: __wasiModule, + napiModule: __napiModule, + } = __emnapiInstantiateNapiModuleSync(__wasmFile, { + context: __emnapiContext, + asyncWorkPoolSize: (function () { + const threadsSizeFromEnv = Number(process.env.NAPI_RS_ASYNC_WORK_POOL_SIZE ?? process.env.UV_THREADPOOL_SIZE) + // NaN > 0 is false + if (threadsSizeFromEnv > 0) { + return threadsSizeFromEnv + } else { + return 4 + } + })(), + reuseWorker: true, + plugins: [ + __captureWasiThreadManager, + __emnapiAsyncWorkPlugin, + __emnapiTSFNPlugin, + ], + wasi: __wasi, + onCreateWorker() { + const worker = __createWasiWorker(__nodePath.join(__dirname, 'wasi-worker.mjs')) + __wasiWorkers.add(worker) + worker.onmessage = ({ data }) => { + __wasmCreateOnMessageForFsProxy(__nodeFs)(data) + } + + // The main thread of Node.js waits for all the active handles before exiting. + // But Rust threads are never waited without `thread::join`. + // So here we hack the code of Node.js to prevent the workers from being referenced (active). + // According to https://github.com/nodejs/node/blob/19e0d472728c79d418b74bddff588bea70a403d0/lib/internal/worker.js#L415, + // a worker is consist of two handles: kPublicPort and kHandle. + { + const kPublicPort = Object.getOwnPropertySymbols(worker).find((s) => + s.toString().includes('kPublicPort'), + ) + if (kPublicPort) { + worker[kPublicPort].ref = () => {} + } + + const kHandle = Object.getOwnPropertySymbols(worker).find((s) => + s.toString().includes('kHandle'), + ) + if (kHandle) { + worker[kHandle].ref = () => {} + } + + worker.unref() + // These stubs stay in place for the worker's whole life, disposal + // included: `__keepEventLoopAliveUntil` is what holds the process open + // while a termination is pending, precisely because a worker's own + // references cannot be relied on for it. + } + return worker + }, + overwriteImports(importObject) { + importObject.env = { + ...importObject.env, + ...importObject.napi, + ...importObject.emnapi, + memory: __sharedMemory, + } + return importObject + }, + beforeInit({ instance }) { + __napiInstance = instance + for (const name of Object.keys(instance.exports)) { + if (name.startsWith('__napi_register__')) { + instance.exports[name]() + } + } + }, + })) + __publishWasiDispose(__napiModule.exports) + // The CommonJS tail below aliases `__napiModule.exports`; a named module + // export does not travel with it, so carry the marker on the binding itself + // too. Three things pin the stamp to exactly this spot: + // - inside this `try`, because the guard throws on a + // `#[napi(module_exports)]` hook that claimed the name, and only the + // catch below tears the environment — context, workers, exit listener — + // back down; + // - after the async runtime host install, which hands this same object to + // addon-provided registration functions that may put anything on it; + // - assigning onto the loader's own `module.exports`, which is still the + // original object here, so an addon accessor with a refusing setter is + // never written through. `cjs-module-lexer` — Node's CJS -> ESM named + // export detection — reads the static `module.exports. =` either + // way, and the later `module.exports = __napiModule.exports` does not + // undo that. + module.exports.__napiBindingTarget = __napiStampBindingTarget(__napiModule.exports, __napiBindingTarget) + __registerWasiExitListener() +} catch (error) { + const rollback = { + active: false, + error, + promise: undefined, + rollback: __rollbackWasiInitialization, + } + __wasiRollbackRegistry.set(__wasiRollbackRegistryKey, rollback) + __runWasiInitializationRollback(rollback) + throw rollback.error +} module.exports = __napiModule.exports +module.exports.BCRYPT_API_VERSION = __napiModule.exports.BCRYPT_API_VERSION module.exports.DEFAULT_COST = __napiModule.exports.DEFAULT_COST module.exports.genSalt = __napiModule.exports.genSalt module.exports.genSaltSync = __napiModule.exports.genSaltSync module.exports.hash = __napiModule.exports.hash module.exports.hashSync = __napiModule.exports.hashSync +module.exports.parseOptions = __napiModule.exports.parseOptions module.exports.verify = __napiModule.exports.verify module.exports.verifySync = __napiModule.exports.verifySync diff --git a/packages/bcrypt/bcrypt.wasi.d.cts b/packages/bcrypt/bcrypt.wasi.d.cts index 06073ad4..806ea498 100644 --- a/packages/bcrypt/bcrypt.wasi.d.cts +++ b/packages/bcrypt/bcrypt.wasi.d.cts @@ -1,2 +1,51 @@ /* auto-generated by NAPI-RS */ -export * from './index.js' +/* eslint-disable */ + +/** The WASI flavor this loader instantiates. */ +export declare const __napiBindingTarget: 'wasm32-wasi' + +/** Internal binding contract, checked by the public JavaScript wrapper. */ +export declare const BCRYPT_API_VERSION: number + +export declare const DEFAULT_COST: number + +export declare function genSalt( + round: number, + version?: string | undefined | null, + signal?: AbortSignal | undefined | null, +): Promise + +export declare function genSaltSync(round: number, version?: string | undefined | null): string + +export declare function hash( + input: string | Uint8Array, + cost: number | undefined | null, + salt: string | Uint8Array | undefined | null, + version: string | undefined | null, + rejectLongPasswords: boolean, + signal?: AbortSignal | undefined | null, +): Promise + +export declare function hashSync( + input: string | Uint8Array, + cost: number | undefined | null, + salt: string | Uint8Array | undefined | null, + version: string | undefined | null, + rejectLongPasswords: boolean, +): string + +/** Prefix and cost of a stored hash, read with the verifier's parser. */ +export interface ParsedHashOptions { + version: string + cost: number +} + +export declare function parseOptions(hash: string | Uint8Array): ParsedHashOptions + +export declare function verify( + password: string | Uint8Array, + hash: string | Uint8Array, + signal?: AbortSignal | undefined | null, +): Promise + +export declare function verifySync(input: string | Uint8Array, hash: string | Uint8Array): boolean diff --git a/packages/bcrypt/benchmark/bcrypt.ts b/packages/bcrypt/benchmark/bcrypt.ts index 96e1ccf7..1dda966b 100644 --- a/packages/bcrypt/benchmark/bcrypt.ts +++ b/packages/bcrypt/benchmark/bcrypt.ts @@ -1,77 +1,130 @@ +// wasm OpenBSD's genSalt win is call overhead only: ~190ns per call vs ~250ns +// of JS -> native marshalling on this side. The hash and verify suites run at +// cost 10 with a fixed salt so they measure the key-expansion loop alone. import openbsd from '@cwasm/openbsd-bcrypt' import openwall from '@cwasm/openwall-bcrypt' -import { hashSync, compare, genSaltSync } from 'bcrypt' import bcryptjs from 'bcryptjs' -import { Bench } from 'tinybench' +import nodeBcrypt from 'bcrypt' +import { Bench, type Task } from 'tinybench' -import { hashSync as napiHashSync, verifySync, genSaltSync as napiGenSaltSync } from '../binding.js' +import { compareSync, genSaltSync, hashSync } from '../index.js' -const password = 'node-rust-password' +const PASSWORD = 'node-rust-password' +const COST = 10 +// Fixed salt: hashing measures the key-expansion loop only, so the suites stay +// comparable when salt generation differs in cost between implementations. +const SALT = '$2b$10$KBCwKxOzLha2MUDgW0PjXe' +const HASH = hashSync(PASSWORD, { salt: SALT }) -const syncHashSuite = new Bench({ - name: 'Hash benchmark', -}) +interface Implementation { + name: string + // Uniform signatures so every task gets the same arguments. + hash: (password: string) => string + verify: (password: string, hash: string) => boolean + genSalt: () => string +} -syncHashSuite - .add('@node-rs/bcrypt', () => { - napiHashSync(password, 10) - }) - .add('node bcrypt', () => { - hashSync(password, 10) - }) - .add('bcryptjs', () => { - bcryptjs.hashSync(password, 10) - }) - .add('wasm OpenBSD', () => { - openbsd.hashSync(password, 10) - }) - .add('wasm Openwall', () => { - openwall.hashSync(password, 10) - }) +const implementations: Implementation[] = [ + { + name: '@node-rs/bcrypt', + hash: (password) => hashSync(password, { salt: SALT }), + verify: (password, hash) => compareSync(password, hash), + genSalt: () => genSaltSync({ cost: COST }), + }, + { + name: 'node bcrypt (C++)', + hash: (password) => nodeBcrypt.hashSync(password, SALT), + verify: (password, hash) => nodeBcrypt.compareSync(password, hash), + genSalt: () => nodeBcrypt.genSaltSync(COST), + }, + { + name: 'bcryptjs', + hash: (password) => bcryptjs.hashSync(password, SALT), + verify: (password, hash) => bcryptjs.compareSync(password, hash), + genSalt: () => bcryptjs.genSaltSync(COST), + }, + { + name: 'wasm OpenBSD', + // The cwasm wrappers generate the salt inside hashSync and take the cost — + // the ~1µs gensalt is included for these two rows only. + hash: (password) => openbsd.hashSync(password, COST), + verify: (password, hash) => openbsd.compareSync(password, hash), + genSalt: () => openbsd.genSaltSync(COST), + }, + { + name: 'wasm Openwall', + hash: (password) => openwall.hashSync(password, COST), + verify: (password, hash) => openwall.compareSync(password, hash), + genSalt: () => openwall.genSaltSync(COST), + }, +] -await syncHashSuite.run() +function formatLatency(ms: number): string { + if (ms >= 1) return `${ms.toFixed(2)} ms` + if (ms >= 0.001) return `${(ms * 1000).toFixed(2)} µs` + return `${(ms * 1e6).toFixed(1)} ns` +} -console.table(syncHashSuite.table()) +function formatOps(opsPerSec: number): string { + if (opsPerSec >= 1e6) return `${(opsPerSec / 1e6).toFixed(2)}M` + if (opsPerSec >= 1e3) return `${(opsPerSec / 1e3).toFixed(2)}k` + return opsPerSec.toFixed(1) +} -const verifySuite = new Bench({ - name: 'Verify benchmark`', -}) -const hashed = napiHashSync(password, 12) -verifySuite - .add('@node-rs/bcrypt', () => { - verifySync(password, hashed) - }) - .add('node bcrypt', () => { - compare(password, hashSync(password, 12)) - }) - .add('bcryptjs', () => { - bcryptjs.compareSync(password, hashed) - }) +function report(bench: Bench, title: string) { + const rows = bench.tasks + .map((task: Task) => { + const r = task.result + // latency/throughput only exist once the task has statistics. + if (r.state !== 'completed' && r.state !== 'aborted-with-statistics') { + throw new Error(`${task.name} has no results (state: ${r.state})`) + } + return { + name: task.name, + latency: r.latency.mean, + rme: r.latency.rme, + ops: r.throughput.mean, + samples: r.latency.samplesCount, + } + }) + .sort((a, b) => a.latency - b.latency) -await verifySuite.run() + const baseline = rows.find((r) => r.name === '@node-rs/bcrypt')?.latency ?? rows[0].latency + const nameWidth = Math.max(...rows.map((r) => r.name.length)) -console.table(verifySuite.table()) + console.log(`\n${title} (password ${PASSWORD.length}B, ${rows[0].samples} samples)`) + console.log(` ${'implementation'.padEnd(nameWidth)} latency ± rme ops/s relative`) + for (const row of rows) { + const ratio = row.latency / baseline + const rel = + row.name === '@node-rs/bcrypt' + ? 'baseline' + : ratio < 1 + ? `${(1 / ratio).toFixed(2)}× faster` + : `${ratio.toFixed(2)}× slower` + console.log( + ` ${row.name.padEnd(nameWidth)} ${formatLatency(row.latency).padStart(9)} ± ${row.rme + .toFixed(2) + .padStart(5)}% ${formatOps(row.ops).padStart(8)} ${rel}`, + ) + } +} -const genSaltSuite = new Bench({ - name: 'GenSalt benchmark', -}) -genSaltSuite - .add('@node-rs/bcrypt', () => { - napiGenSaltSync(12) - }) - .add('node bcrypt', () => { - genSaltSync(12) - }) - .add('bcryptjs', () => { - bcryptjs.genSaltSync(12) - }) - .add('wasm OpenBSD', () => { - openbsd.genSaltSync(12) - }) - .add('wasm Openwall', () => { - openwall.genSaltSync(12) - }) +const hashBench = new Bench({ name: 'hash' }) +const verifyBench = new Bench({ name: 'verify' }) +const genSaltBench = new Bench({ name: 'genSalt' }) -await genSaltSuite.run() +for (const impl of implementations) { + hashBench.add(impl.name, () => impl.hash(PASSWORD)) + verifyBench.add(impl.name, () => impl.verify(PASSWORD, HASH)) + genSaltBench.add(impl.name, () => impl.genSalt()) +} -console.table(genSaltSuite.table()) +for (const [bench, title] of [ + [hashBench, `hashSync(password, fixed-salt-cost-${COST})`], + [verifyBench, `verifySync(password, hash)`], + [genSaltBench, `genSaltSync(cost ${COST})`], +] as const) { + await bench.run() + report(bench, title) +} diff --git a/packages/bcrypt/binding.d.ts b/packages/bcrypt/binding.d.ts index d37a9867..778f8b79 100644 --- a/packages/bcrypt/binding.d.ts +++ b/packages/bcrypt/binding.d.ts @@ -1,15 +1,35 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -export const DEFAULT_COST: number -export declare function genSalt(round: number, version?: '2a' | '2x' | '2y' | '2b', signal?: AbortSignal): Promise +/** + * Which binding artifact the generated loader actually loaded: `'native'` for + * a native addon, otherwise the `platformArchABI` of the WASI flavor. Every + * flavor napi-rs can build is listed, because `NAPI_RS_NATIVE_LIBRARY_PATH` + * can point the loader at a WASI artifact this package does not build itself. + */ +export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1' -export declare function genSaltSync(round: number, version?: '2a' | '2x' | '2y' | '2b'): string +/** Internal binding contract, checked by the public JavaScript wrapper. */ +export declare const BCRYPT_API_VERSION: number -export declare function hash(input: Uint8Array | string, cost?: number | undefined | null, salt?: string | Uint8Array | undefined | null, signal?: AbortSignal | undefined | null): Promise +export declare const DEFAULT_COST: number -export declare function hashSync(input: string | Uint8Array, cost?: number | undefined | null, salt?: string | Uint8Array | undefined | null): string +export declare function genSalt(round: number, version?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise -export declare function verify(password: Uint8Array | string, hash: Uint8Array | string, signal?: AbortSignal | undefined | null): Promise +export declare function genSaltSync(round: number, version?: string | undefined | null): string + +export declare function hash(input: string | Uint8Array, cost: number | undefined | null, salt: string | Uint8Array | undefined | null, version: string | undefined | null, rejectLongPasswords: boolean, signal?: AbortSignal | undefined | null): Promise + +export declare function hashSync(input: string | Uint8Array, cost: number | undefined | null, salt: string | Uint8Array | undefined | null, version: string | undefined | null, rejectLongPasswords: boolean): string + +/** Prefix and cost of a stored hash, read with the verifier's parser. */ +export interface ParsedHashOptions { + version: string + cost: number +} + +export declare function parseOptions(hash: string | Uint8Array): ParsedHashOptions + +export declare function verify(password: string | Uint8Array, hash: string | Uint8Array, signal?: AbortSignal | undefined | null): Promise export declare function verifySync(input: string | Uint8Array, hash: string | Uint8Array): boolean diff --git a/packages/bcrypt/binding.js b/packages/bcrypt/binding.js index 799aa3fa..741784d1 100644 --- a/packages/bcrypt/binding.js +++ b/packages/bcrypt/binding.js @@ -1,10 +1,13 @@ -// prettier-ignore /* eslint-disable */ // @ts-nocheck /* auto-generated by NAPI-RS */ const { readFileSync } = require('fs') let nativeBinding = null +// Which artifact actually loaded. The WASI fallback chain overwrites it with +// the flavor it resolved; the late native retry below leaves it alone because +// it only runs while no WASI candidate has been loaded. +let __napiLoadedBindingTarget = 'native' const loadErrors = [] const isMusl = () => { @@ -63,7 +66,16 @@ const isMuslFromChildProcess = () => { function requireNative() { if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { try { - return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + const overrideBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH) + // The override may be a generated WASI loader, which already reports its + // own flavor. Adopt it: `module.exports` aliases this object, so claiming + // 'native' would both misreport the artifact and overwrite the loader's + // marker through the alias. + __napiLoadedBindingTarget = + overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string' + ? overrideBinding.__napiBindingTarget + : 'native' + return overrideBinding } catch (err) { loadErrors.push(err) } @@ -77,8 +89,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-android-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-android-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +105,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-android-arm-eabi') const bindingPackageVersion = require('@node-rs/bcrypt-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -107,36 +119,36 @@ function requireNative() { if (process.arch === 'x64') { if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { try { - return require('./bcrypt.win32-x64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@node-rs/bcrypt-win32-x64-gnu') - const bindingPackageVersion = require('@node-rs/bcrypt-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + return require('./bcrypt.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@node-rs/bcrypt-win32-x64-gnu') + const bindingPackageVersion = require('@node-rs/bcrypt-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) } - return binding - } catch (e) { - loadErrors.push(e) - } } else { try { - return require('./bcrypt.win32-x64-msvc.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@node-rs/bcrypt-win32-x64-msvc') - const bindingPackageVersion = require('@node-rs/bcrypt-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + return require('./bcrypt.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@node-rs/bcrypt-win32-x64-msvc') + const bindingPackageVersion = require('@node-rs/bcrypt-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) } - return binding - } catch (e) { - loadErrors.push(e) - } } } else if (process.arch === 'ia32') { try { @@ -147,8 +159,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-win32-ia32-msvc') const bindingPackageVersion = require('@node-rs/bcrypt-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +175,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-win32-arm64-msvc') const bindingPackageVersion = require('@node-rs/bcrypt-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +194,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-darwin-universal') const bindingPackageVersion = require('@node-rs/bcrypt-darwin-universal/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +210,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-darwin-x64') const bindingPackageVersion = require('@node-rs/bcrypt-darwin-x64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +226,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-darwin-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-darwin-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +246,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-freebsd-x64') const bindingPackageVersion = require('@node-rs/bcrypt-freebsd-x64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +262,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-freebsd-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +283,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-x64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +299,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-x64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +317,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +333,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +351,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm-musleabihf') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +367,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-arm-gnueabihf') const bindingPackageVersion = require('@node-rs/bcrypt-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +385,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-loong64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +401,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-loong64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +419,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-riscv64-musl') const bindingPackageVersion = require('@node-rs/bcrypt-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +435,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-riscv64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +452,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-ppc64-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +468,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-linux-s390x-gnu') const bindingPackageVersion = require('@node-rs/bcrypt-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +488,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-openharmony-arm64') const bindingPackageVersion = require('@node-rs/bcrypt-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +504,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-openharmony-x64') const bindingPackageVersion = require('@node-rs/bcrypt-openharmony-x64/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +520,8 @@ function requireNative() { try { const binding = require('@node-rs/bcrypt-openharmony-arm') const bindingPackageVersion = require('@node-rs/bcrypt-openharmony-arm/package.json').version - if (bindingPackageVersion !== '1.10.7' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -550,7 +562,7 @@ function createLoadErrorChain(errors) { // // NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict // WASI loading. It never crosses into another flavor or falls back to native. -const __napiWasiFlavors = ["wasm32-wasi"] +const __napiWasiFlavors = ['wasm32-wasi'] const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR const __napiWasiFlavorRequested = typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 @@ -619,15 +631,16 @@ if (!nativeBinding || forceWasi) { } return null } - if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { let candidateError = null let candidateFailed = false try { - candidateError = __napiWasiResolveCandidate('./bcrypt.wasi.cjs', false, ["./bcrypt.wasm32-wasi.debug.wasm","./bcrypt.wasm32-wasi.wasm"]) + candidateError = __napiWasiResolveCandidate('./bcrypt.wasi.cjs', false, ['./bcrypt.wasm32-wasi.debug.wasm', './bcrypt.wasm32-wasi.wasm']) candidateFailed = candidateError !== null if (!candidateFailed) { wasiBinding = require('./bcrypt.wasi.cjs') nativeBinding = wasiBinding + __napiLoadedBindingTarget = 'wasm32-wasi' wasiBindingLoaded = true } } catch (err) { @@ -639,7 +652,7 @@ if (!nativeBinding || forceWasi) { loadErrors.push(candidateError) } } - if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { let candidateError = null let candidateFailed = false try { @@ -648,12 +661,13 @@ if (!nativeBinding || forceWasi) { if (!candidateFailed) { if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { const bindingPackageVersion = require('@node-rs/bcrypt-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '1.10.7') { - throw new Error(`WASI binding package version mismatch, expected 1.10.7 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.10.9') { + throw new Error(`WASI binding package version mismatch, expected 1.10.9 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } } wasiBinding = require('@node-rs/bcrypt-wasm32-wasi') nativeBinding = wasiBinding + __napiLoadedBindingTarget = 'wasm32-wasi' wasiBindingLoaded = true } } catch (err) { @@ -699,11 +713,77 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } +function __napiStampBindingTarget(exportsObject, target) { + if ( + Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget') + ) { + if (exportsObject.__napiBindingTarget === target) { + // Already ours: the root entry aliases the object it loaded, so a WASI + // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that + // is a generated loader — arrives already stamped with this same value. + return target + } + const error = new Error( + '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].', + ) + error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT' + throw error + } + if (!Object.isExtensible(exportsObject)) { + // A `#[napi(module_exports)]` hook may seal or freeze this object + // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata, + // never a reason to fail an otherwise successful load, so the stamp is + // skipped. What a consumer still sees then follows the entry point: the + // browser and deferred loaders declare `__napiBindingTarget` at module + // level and go on reporting it, while the CommonJS entries hand back this + // very object as `module.exports`, so there the value is absent. + return target + } + try { + // [[Define]], not [[Set]]: an ordinary assignment walks the prototype + // chain, so an inherited accessor could swallow the value or throw and + // fail an otherwise successful load. The descriptor is what a successful + // assignment would have produced. + Object.defineProperty(exportsObject, '__napiBindingTarget', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }) + } catch { + // Same rule as the non-extensible skip above: reporting the artifact is + // metadata, never a reason to fail an otherwise successful load. An exotic + // object (a Proxy whose defineProperty trap refuses) is skipped, not + // thrown over. + } + // The CommonJS loaders assign this return value so `cjs-module-lexer` — and + // therefore Node's CJS -> ESM named export detection — can see + // `__napiBindingTarget` statically. + return target +} +// Stamp before the alias, not after. The guard only reads `nativeBinding` +// (`hasOwnProperty` plus a comparison), which is safe against any addon +// accessor; an assignment is not, because a `#[napi(module_exports)]` hook can +// expose a getter reporting this very value and a setter that throws. So the +// assignment lands on the loader's own `module.exports`, still the original +// object here, and the alias below replaces it. +// +// The assignment is what keeps the marker a statically visible CommonJS export: +// `cjs-module-lexer` is Node's CJS -> ESM named export detection, it cannot see +// a bare call, and the later `module.exports = nativeBinding` does not undo the +// detection. The assignment itself always succeeds — its target is this +// loader's own, still extensible `module.exports` — and the alias below then +// discards the value it wrote. What a consumer reads is whatever the guard put +// on `nativeBinding`, so on a frozen binding, where the guard skips, the +// linked import resolves to `undefined`. +module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget) module.exports = nativeBinding +module.exports.BCRYPT_API_VERSION = nativeBinding.BCRYPT_API_VERSION module.exports.DEFAULT_COST = nativeBinding.DEFAULT_COST module.exports.genSalt = nativeBinding.genSalt module.exports.genSaltSync = nativeBinding.genSaltSync module.exports.hash = nativeBinding.hash module.exports.hashSync = nativeBinding.hashSync +module.exports.parseOptions = nativeBinding.parseOptions module.exports.verify = nativeBinding.verify module.exports.verifySync = nativeBinding.verifySync diff --git a/packages/bcrypt/browser-entry.js b/packages/bcrypt/browser-entry.js new file mode 100644 index 00000000..37badaf6 --- /dev/null +++ b/packages/bcrypt/browser-entry.js @@ -0,0 +1,17 @@ +import binding from '@node-rs/bcrypt-wasm32-wasi' +import createBcrypt from './api.cjs' + +// Keep the public adapter separate from browser.js, which napi build regenerates. +const api = createBcrypt(binding) +export const { + DEFAULT_COST, + genSalt, + genSaltSync, + hash, + hashSync, + verify, + verifySync, + compare, + compareSync, + parseOptions, +} = api diff --git a/packages/bcrypt/index.d.ts b/packages/bcrypt/index.d.ts index 2a7429f4..25ff6276 100644 --- a/packages/bcrypt/index.d.ts +++ b/packages/bcrypt/index.d.ts @@ -1,6 +1,99 @@ -import { verify, verifySync } from './binding' +export type Password = string | Uint8Array +/** Creation versions only; verification retains existing prefix handling independently. */ +export type Version = '2a' | '2b' | '2y' +/** Prefixes the retained verifier accepts; imported `2x` labels are verified with the standard algorithm. */ +export type StoredVersion = Version | '2x' +export declare const DEFAULT_COST: number // Remains 12. -export const compare: typeof verify -export const compareSync: typeof verifySync +/** + * Error contract. Wrong argument or option types throw `TypeError` with + * `code: 'ERR_INVALID_ARG_TYPE'`; values of the right type that are out of range + * or malformed throw `RangeError` with `code: 'ERR_OUT_OF_RANGE'`; cancellation + * rejects with `name: 'AbortError'` and `code: 'ABORT_ERR'`, like Node's AbortError. + * Loading the package over a backend built for another major throws `Error` with + * `code: 'ERR_BCRYPT_INCOMPATIBLE_BINARY'`. + */ +export type ErrorCode = 'ERR_INVALID_ARG_TYPE' | 'ERR_OUT_OF_RANGE' | 'ABORT_ERR' | 'ERR_BCRYPT_INCOMPATIBLE_BINARY' -export * from './binding' +/** The cancellation interface used from native AbortSignals and compatible polyfills. */ +export interface AbortSignalLike { + readonly aborted: boolean + /** When defined at abort time, becomes the `cause` of the AbortError. */ + readonly reason?: unknown + addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean; capture?: boolean }): void + /** Receives the same options object the listener was added with. */ + removeEventListener(type: 'abort', listener: () => void, options?: { once?: boolean; capture?: boolean }): void +} + +export interface AsyncOptions { + /** + * For a valid call: pre-aborted signals reject with name AbortError before queueing. + * Later abort rejects the pending public Promise with AbortError; running native work + * may finish in the background. The error's `cause` is `signal.reason` when defined, + * e.g. a TimeoutError from `AbortSignal.timeout()`. First observed settlement wins. Signal handlers + * are preserved; shared/reused signals work independently for each operation. + * Locally imported polyfills work without installing global constructors. + */ + signal?: AbortSignalLike +} + +export interface SaltOptions { + /** Finite integer 4..31; defaults to 12. */ + cost?: number + /** Defaults to 2b. 2x generation is removed. */ + version?: Version +} + +export type HashOptions = ( + | { salt?: Uint8Array; cost?: number; version?: Version } + | { salt: string; cost?: never; version?: never } +) & { + /** Defaults to false. Optional creation policy; never imposed on verification. */ + rejectLongPasswords?: boolean +} + +/** Canonical 29-character salt; async failures reject the returned Promise. */ +export declare function genSalt(options?: SaltOptions & AsyncOptions): Promise +export declare function genSaltSync(options?: SaltOptions): string + +/** + * Omitted salt generates 16 random bytes. Raw salts must contain exactly 16 bytes. + * String salts: exactly 29 ASCII characters, prefix 2a/2b/2y, two decimal cost + * digits in 04..31, and canonical bcrypt Base64 encoding exactly 16 salt bytes. + * Embedded 2x prefixes, +4 costs, padding, and noncanonical trailing bits are rejected + * during creation. Encoded salts supply their own cost/version. + * No positional legacy overload or implicit salt clipping/padding remains. + * Passwords retain existing bcrypt byte/truncation semantics by default. + */ +export declare function hash(password: Password, options?: HashOptions & AsyncOptions): Promise +export declare function hashSync(password: Password, options?: HashOptions): string + +/** + * Same password bytes and stored hash retain their previous verification result. + * No new length restriction, default cost ceiling, normalization, or prefix computation. + * Async argument errors reject. Existing successfully parsed encodings stay supported, + * including cost +4; creation's strict parser must not gate verification. + * Encodings rejected by the retained verifier parser and password mismatches return false. + * Invalid UTF-8 hash bytes also return false under the new error contract. + * Caller options cannot override the stored salt, cost, or version. + */ +export declare function verify(password: Password, encodedHash: string | Uint8Array, options?: AsyncOptions): Promise +export declare function verifySync(password: Password, encodedHash: string | Uint8Array): boolean +export declare const compare: typeof verify +export declare const compareSync: typeof verifySync + +/** Parameters a stored hash was created with, as read by the verifier's parser. */ +export interface ParsedHashOptions { + version: StoredVersion + /** Effective cost, 4..31. Noncanonical spellings such as `+4` are reported as their value. */ + cost: number +} + +/** + * Reads the version and cost of a stored hash with the same parser `verify` uses, so every hash + * `verify` can accept is parseable. Compare the result against your current policy for + * rehash-on-login checks. Hashes `verify` always rejects (malformed text, invalid UTF-8 bytes, + * costs outside 4..31) throw `RangeError` instead of returning unusable parameters. + * The stricter creation parser is not involved. + */ +export declare function parseOptions(encodedHash: string | Uint8Array): ParsedHashOptions diff --git a/packages/bcrypt/index.js b/packages/bcrypt/index.js index a6f3badc..5abc5b83 100644 --- a/packages/bcrypt/index.js +++ b/packages/bcrypt/index.js @@ -1,4 +1,7 @@ -const { DEFAULT_COST, genSaltSync, genSalt, hashSync, hash, verifySync, verify } = require('./binding') +const createBcrypt = require('./api.cjs') +const { DEFAULT_COST, genSaltSync, genSalt, hashSync, hash, verifySync, verify, parseOptions } = createBcrypt( + require('./binding'), +) module.exports.DEFAULT_COST = DEFAULT_COST module.exports.genSaltSync = genSaltSync @@ -9,3 +12,4 @@ module.exports.verifySync = verifySync module.exports.verify = verify module.exports.compareSync = verifySync module.exports.compare = verify +module.exports.parseOptions = parseOptions diff --git a/packages/bcrypt/package.json b/packages/bcrypt/package.json index c11714ae..267e2bec 100644 --- a/packages/bcrypt/package.json +++ b/packages/bcrypt/package.json @@ -30,15 +30,25 @@ "files": [ "binding.d.ts", "binding.js", - "browser.js", + "browser-entry.js", "index.d.ts", "index.js", "LICENSE", - "LICENSE.rust-bcrypt" + "LICENSE.rust-bcrypt", + "api.cjs", + "MIGRATION.md" ], "main": "index.js", - "browser": "browser.js", + "browser": "browser-entry.js", "typings": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "browser": "./browser-entry.js", + "default": "./index.js" + }, + "./package.json": "./package.json" + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" @@ -47,7 +57,7 @@ "artifacts": "napi artifacts -d ../../artifacts", "bench": "cross-env NODE_ENV=production node --import @oxc-node/core/register benchmark/bcrypt.ts", "build": "napi build --platform --release --js binding.js --dts binding.d.ts", - "build:debug": "napi build --platform", + "build:debug": "napi build --platform --js binding.js --dts binding.d.ts", "prepublishOnly": "napi prepublish", "version": "napi version" }, @@ -55,9 +65,12 @@ "@cwasm/openbsd-bcrypt": "^0.1.0", "@cwasm/openwall-bcrypt": "^0.1.0", "@napi-rs/cli": "^3.8.6", + "@node-rs/bcrypt-wasm32-wasi": "1.10.9", "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^3.0.0", + "abort-controller": "3.0.0", "bcrypt": "^6.0.0", + "bcrypt-previous": "npm:@node-rs/bcrypt@1.10.9", "bcryptjs": "^3.0.3", "cross-env": "^10.1.0", "tinybench": "^6.1.3" @@ -79,7 +92,12 @@ "i686-pc-windows-msvc", "armv7-linux-androideabi", "wasm32-wasip1-threads" - ] + ], + "wasm": { + "browser": { + "fs": false + } + } }, "engines": { "node": ">= 10" diff --git a/packages/bcrypt/src/hash_task.rs b/packages/bcrypt/src/hash_task.rs index 094b9875..bb31a873 100644 --- a/packages/bcrypt/src/hash_task.rs +++ b/packages/bcrypt/src/hash_task.rs @@ -1,25 +1,38 @@ -use napi::{ - Env, Error, Result, Status, Task, - bindgen_prelude::{Either, Uint8Array}, -}; +use bcrypt::Version; +use napi::{Env, Error, Result, Status, Task}; use napi_derive::napi; +use zeroize::Zeroizing; + +/// bcrypt never reads past this many password bytes. +const MAX_PASSWORD_BYTES: usize = 72; + +/// Owned copy for async work. Only the bytes bcrypt reads are kept, so the hash is +/// unchanged, and the copy is wiped when the task is dropped. +pub(crate) fn owned_password(password: &[u8]) -> Zeroizing> { + Zeroizing::new(password[..password.len().min(MAX_PASSWORD_BYTES)].to_vec()) +} pub struct HashTask { - buf: Either, - cost: u32, - salt: [u8; 16], + pub(crate) password: Zeroizing>, + pub(crate) cost: u32, + pub(crate) salt: [u8; 16], + pub(crate) version: Version, } impl HashTask { - #[inline] - pub fn new(buf: Either, cost: u32, salt: [u8; 16]) -> HashTask { - HashTask { buf, cost, salt } + pub fn validate_password(password: &[u8], reject_long_passwords: bool) -> Result<()> { + if reject_long_passwords && password.len() > MAX_PASSWORD_BYTES { + return Err(Error::new( + Status::InvalidArg, + "password must not exceed 72 bytes", + )); + } + Ok(()) } - #[inline] - pub fn hash(buf: &[u8], salt: [u8; 16], cost: u32) -> Result { - bcrypt::hash_with_salt(buf, cost, salt) - .map(|hash_part| hash_part.to_string()) + pub fn hash(password: &[u8], cost: u32, salt: [u8; 16], version: Version) -> Result { + bcrypt::hash_with_salt(password, cost, salt) + .map(|parts| parts.format_for_version(version)) .map_err(|err| Error::new(Status::GenericFailure, format!("{err}"))) } } @@ -30,7 +43,7 @@ impl Task for HashTask { type JsValue = String; fn compute(&mut self) -> Result { - Self::hash(self.buf.as_ref(), self.salt, self.cost) + Self::hash(&self.password, self.cost, self.salt, self.version) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { diff --git a/packages/bcrypt/src/lib.rs b/packages/bcrypt/src/lib.rs index ed8b91b1..b728c2e9 100644 --- a/packages/bcrypt/src/lib.rs +++ b/packages/bcrypt/src/lib.rs @@ -4,112 +4,101 @@ /// Explicit extern crate to use allocator. extern crate global_alloc; -use std::cmp; - -use bcrypt::Version; use napi::bindgen_prelude::*; use napi_derive::*; -use crate::hash_task::HashTask; +use crate::hash_task::{HashTask, owned_password}; +use crate::options::{hash_options, validate_cost, version_from_str}; use crate::salt_task::{format_salt, gen_salt}; -use crate::verify_task::VerifyTask; +use crate::verify_task::{ParsedHashOptions, VerifyTask, parse_stored_hash}; mod hash_task; +mod options; mod salt_task; mod verify_task; #[napi] pub const DEFAULT_COST: u32 = 12; -#[napi(ts_args_type = "round: number, version?: '2a' | '2x' | '2y' | '2b'")] -pub fn gen_salt_sync(round: u32, version: Option) -> Result { - let salt = gen_salt(); - Ok(format_salt(round, &version_from_str(version)?, &salt)) +/// Internal binding contract, checked by the public JavaScript wrapper. +#[napi] +pub const BCRYPT_API_VERSION: u32 = 2; + +#[napi] +pub fn gen_salt_sync(round: f64, version: Option) -> Result { + let round = validate_cost(round)?; + Ok(format_salt( + round, + &version_from_str(version.as_deref())?, + &gen_salt(), + )) } -#[napi( - js_name = "genSalt", - ts_args_type = "round: number, version?: '2a' | '2x' | '2y' | '2b', signal?: AbortSignal" -)] +#[napi(js_name = "genSalt")] pub fn gen_salt_js( - round: u32, + round: f64, version: Option, signal: Option, ) -> Result> { let task = salt_task::SaltTask { - round, - version: version_from_str(version)?, + round: validate_cost(round)?, + version: version_from_str(version.as_deref())?, }; Ok(AsyncTask::with_optional_signal(task, signal)) } #[napi] -#[inline] pub fn hash_sync( input: Either, - cost: Option, + cost: Option, salt: Option>, + version: Option, + reject_long_passwords: bool, ) -> Result { - let salt = if let Some(salt) = salt { - let mut s = [0u8; 16]; - let buf = salt.as_ref(); - // make sure salt buffer length should be 16 - let copy_length = cmp::min(buf.len(), s.len()); - s[..copy_length].copy_from_slice(&buf[..copy_length]); - s - } else { - rand::random() - }; - HashTask::hash(input.as_ref(), salt, cost.unwrap_or(DEFAULT_COST)) + let options = hash_options(cost, salt, version)?; + HashTask::validate_password(input.as_ref(), reject_long_passwords)?; + HashTask::hash(input.as_ref(), options.cost, options.salt, options.version) } #[napi] pub fn hash( - input: Either, - cost: Option, + input: Either, + cost: Option, salt: Option>, + version: Option, + reject_long_passwords: bool, signal: Option, ) -> Result> { - let salt = if let Some(salt) = salt { - let mut s = [0u8; 16]; - let buf = salt.as_ref(); - // make sure salt buffer length should be 16 - let copy_length = cmp::min(buf.len(), s.len()); - s[..copy_length].copy_from_slice(&buf[..copy_length]); - s - } else { - gen_salt() + let options = hash_options(cost, salt, version)?; + HashTask::validate_password(input.as_ref(), reject_long_passwords)?; + let task = HashTask { + password: owned_password(input.as_ref()), + cost: options.cost, + salt: options.salt, + version: options.version, }; - let task = HashTask::new(input, cost.unwrap_or(DEFAULT_COST), salt); Ok(AsyncTask::with_optional_signal(task, signal)) } #[napi] -#[inline] -pub fn verify_sync(input: Either, hash: Either) -> Result { - VerifyTask::verify(input, hash) +pub fn verify_sync(input: Either, hash: Either) -> bool { + VerifyTask::verify(input.as_ref(), hash.as_ref()) } #[napi] pub fn verify( - password: Either, - hash: Either, + password: Either, + hash: Either, signal: Option, ) -> Result> { - let task = VerifyTask::new(password, hash); + let task = VerifyTask { + password: owned_password(password.as_ref()), + hash: hash.as_ref().to_vec(), + }; Ok(AsyncTask::with_optional_signal(task, signal)) } -#[inline] -fn version_from_str(version: Option) -> Result { - match version.as_deref() { - Some("2a") => Ok(Version::TwoA), - Some("2b") | None => Ok(Version::TwoB), - Some("2x") => Ok(Version::TwoX), - Some("2y") => Ok(Version::TwoY), - Some(version) => Err(Error::new( - Status::InvalidArg, - format!("{version} is not a valid version"), - )), - } +#[napi] +pub fn parse_options(hash: Either) -> Result { + parse_stored_hash(hash.as_ref()) } diff --git a/packages/bcrypt/src/options.rs b/packages/bcrypt/src/options.rs new file mode 100644 index 00000000..0116c250 --- /dev/null +++ b/packages/bcrypt/src/options.rs @@ -0,0 +1,101 @@ +use base64::engine::Engine; +use bcrypt::Version; +use napi::bindgen_prelude::*; + +use crate::DEFAULT_COST; +use crate::salt_task::{gen_salt, salt_engine}; + +pub(crate) struct HashOptions { + pub cost: u32, + pub salt: [u8; 16], + pub version: Version, +} + +pub(crate) fn validate_cost(cost: f64) -> Result { + if !cost.is_finite() || cost.fract() != 0.0 || !(4.0..=31.0).contains(&cost) { + return Err(Error::new( + Status::InvalidArg, + "cost must be an integer between 4 and 31", + )); + } + Ok(cost as u32) +} + +pub(crate) fn version_from_str(version: Option<&str>) -> Result { + match version { + Some("2a") => Ok(Version::TwoA), + Some("2b") | None => Ok(Version::TwoB), + Some("2y") => Ok(Version::TwoY), + _ => Err(Error::new( + Status::InvalidArg, + "version must be 2a, 2b, or 2y", + )), + } +} + +// Creation is strict. Never use this parser to gate verification of stored hashes. +pub(crate) fn hash_options( + cost: Option, + salt: Option>, + version: Option, +) -> Result { + let raw_salt = match salt { + Some(Either::A(encoded)) => { + if cost.is_some() || version.is_some() { + return Err(Error::new( + Status::InvalidArg, + "an encoded salt already supplies cost and version", + )); + } + return parse_encoded_salt(&encoded); + } + Some(Either::B(bytes)) => Some(bytes), + None => None, + }; + let cost = validate_cost(cost.unwrap_or(DEFAULT_COST as f64))?; + let version = version_from_str(version.as_deref())?; + let salt: [u8; 16] = match raw_salt { + Some(bytes) => bytes + .try_into() + .map_err(|_| Error::new(Status::InvalidArg, "raw salt must contain exactly 16 bytes"))?, + None => gen_salt(), + }; + Ok(HashOptions { + cost, + salt, + version, + }) +} + +fn parse_encoded_salt(encoded: &str) -> Result { + let invalid = || { + Error::new( + Status::InvalidArg, + "salt must be a canonical 29-character bcrypt salt", + ) + }; + if encoded.len() != 29 || !encoded.is_ascii() { + return Err(invalid()); + } + let bytes = encoded.as_bytes(); + if bytes[0] != b'$' + || bytes[3] != b'$' + || bytes[6] != b'$' + || !bytes[4].is_ascii_digit() + || !bytes[5].is_ascii_digit() + { + return Err(invalid()); + } + let version = version_from_str(Some(&encoded[1..3]))?; + let cost = validate_cost(((bytes[4] - b'0') * 10 + bytes[5] - b'0') as f64)?; + let decoded = salt_engine().decode(&encoded[7..]).map_err(|_| invalid())?; + let salt: [u8; 16] = decoded.try_into().map_err(|_| invalid())?; + if salt_engine().encode(salt) != encoded[7..] { + return Err(invalid()); + } + Ok(HashOptions { + cost, + salt, + version, + }) +} diff --git a/packages/bcrypt/src/salt_task.rs b/packages/bcrypt/src/salt_task.rs index e11cf4a3..59c5bb0d 100644 --- a/packages/bcrypt/src/salt_task.rs +++ b/packages/bcrypt/src/salt_task.rs @@ -1,23 +1,67 @@ use base64::engine::Engine; use napi::{Env, Result, Task}; use napi_derive::napi; +use rand::TryRng; +use rand::rngs::SysRng; -use crate::Version; +use bcrypt::Version; + +// One getrandom syscall amortized over POOL_BYTES / 16 salts; the pool always +// holds raw OS entropy, never expanded by a userspace PRNG. +const POOL_BYTES: usize = 256; + +thread_local! { + static ENTROPY_POOL: std::cell::RefCell<([u8; POOL_BYTES], usize)> = + const { std::cell::RefCell::new(([0; POOL_BYTES], POOL_BYTES)) }; +} #[inline] pub(crate) fn gen_salt() -> [u8; 16] { - rand::random() + ENTROPY_POOL.with(|pool| { + let mut pool = pool.borrow_mut(); + let (bytes, offset) = &mut *pool; + if *offset > POOL_BYTES - 16 { + SysRng + .try_fill_bytes(bytes) + .expect("OS entropy source is unavailable"); + *offset = 0; + } + let salt: [u8; 16] = bytes[*offset..*offset + 16].try_into().unwrap(); + *offset += 16; + salt + }) } #[inline] pub(crate) fn format_salt(rounds: u32, version: &Version, salt: &[u8; 16]) -> String { - let mut base64_string = String::new(); - let engine = base64::engine::general_purpose::GeneralPurpose::new( - &base64::alphabet::BCRYPT, - base64::engine::general_purpose::PAD, - ); - engine.encode_string(salt, &mut base64_string); - format!("${version}${rounds:0>2}${base64_string}") + let marker = match version { + Version::TwoA => 'a', + Version::TwoX => 'x', + Version::TwoY => 'y', + Version::TwoB => 'b', + }; + let mut out = String::with_capacity(29); + out.push_str("$2"); + out.push(marker); + out.push('$'); + out.push((b'0' + (rounds / 10) as u8) as char); + out.push((b'0' + (rounds % 10) as u8) as char); + out.push('$'); + salt_engine().encode_string(salt, &mut out); + out +} + +// GeneralPurpose::new re-runs alphabet/config validation per construction; the +// engine is immutable, so build it once. +pub(crate) fn salt_engine() -> &'static base64::engine::general_purpose::GeneralPurpose { + static ENGINE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + base64::engine::general_purpose::GeneralPurpose::new( + &base64::alphabet::BCRYPT, + base64::engine::general_purpose::NO_PAD, + ) + }); + &ENGINE } pub struct SaltTask { diff --git a/packages/bcrypt/src/verify_task.rs b/packages/bcrypt/src/verify_task.rs index 794d29ec..6239147d 100644 --- a/packages/bcrypt/src/verify_task.rs +++ b/packages/bcrypt/src/verify_task.rs @@ -1,31 +1,99 @@ use std::str; +use base64::engine::Engine; use napi::bindgen_prelude::*; use napi_derive::napi; +use zeroize::Zeroizing; -pub struct VerifyTask { - password: Either, - hash: Either, +use crate::salt_task::salt_engine; + +const HASH_STRING_LEN: usize = 60; + +/// A stored hash read with the verifier's lenient parser: `bcrypt-rust`'s +/// `HashParts::from_str` is deliberately strict, but verification must keep +/// accepting every hash the previously shipped backend accepted (e.g. the +/// noncanonical `+4` cost spelling that `str::parse::` allows). +/// Cost is not range-checked here — a parseable but out-of-range cost makes +/// verification return `Ok(false)`-equivalent, matching the old backend, +/// which rejected it at hash time rather than at parse time. +struct StoredHash { + cost: u32, + salt: [u8; 16], + hash: [u8; 23], } -impl VerifyTask { - pub fn new(password: Either, hash: Either) -> VerifyTask { - Self { password, hash } +fn split_hash_lenient(hash: &[u8]) -> Option { + // Same contract as the old backend's parser: exactly 60 ASCII bytes with + // `$` separators, a `$2a$`-family version marker, and both payload fields + // valid bcrypt base64. + if hash.len() != HASH_STRING_LEN || !hash.is_ascii() { + return None; + } + if hash[0] != b'$' || hash[3] != b'$' || hash[6] != b'$' { + return None; + } + if hash[1] != b'2' || !matches!(hash[2], b'a' | b'b' | b'x' | b'y') { + return None; + } + // `u32::parse` accepts an optional leading `+`; that leniency is load-bearing + // for hashes written by other implementations. + let cost = str::from_utf8(&hash[4..6]).ok()?.parse::().ok()?; + let salt: [u8; 16] = salt_engine().decode(&hash[7..29]).ok()?.try_into().ok()?; + let hash_bytes: [u8; 23] = salt_engine().decode(&hash[29..60]).ok()?.try_into().ok()?; + Some(StoredHash { + cost, + salt, + hash: hash_bytes, + }) +} + +/// Prefix and cost of a stored hash, read with the verifier's parser. +#[napi(object)] +pub struct ParsedHashOptions { + pub version: String, + pub cost: u32, +} + +/// Uses the same parser as verification, so every hash `verify` can accept is parseable, +/// including noncanonical costs and imported `2x` labels. Hashes `verify` always rejects +/// are errors here rather than unusable parameters. +pub(crate) fn parse_stored_hash(hash: &[u8]) -> Result { + let invalid = |message| Error::new(Status::InvalidArg, message); + let encoded = + str::from_utf8(hash).map_err(|_| invalid("hash must be a bcrypt hash accepted by verify"))?; + let parts = split_hash_lenient(hash) + .ok_or_else(|| invalid("hash must be a bcrypt hash accepted by verify"))?; + if !(4..=31).contains(&parts.cost) { + return Err(invalid("hash cost must be between 4 and 31")); } + // The parser guarantees 60 ASCII bytes with `$2` at the start. + Ok(ParsedHashOptions { + version: encoded[1..3].to_string(), + cost: parts.cost, + }) +} - #[inline] - pub fn verify(password: P, hash: H) -> Result - where - P: AsRef<[u8]>, - H: AsRef<[u8]>, - { - Ok( - bcrypt::verify( - password, - str::from_utf8(hash.as_ref()).map_err(|_| Error::from_status(Status::StringExpected))?, - ) - .unwrap_or(false), - ) +pub struct VerifyTask { + pub(crate) password: Zeroizing>, + pub(crate) hash: Vec, +} + +impl VerifyTask { + pub fn verify(password: &[u8], hash: &[u8]) -> bool { + let Some(parts) = split_hash_lenient(hash) else { + return false; + }; + // Canonicalize and hand off to the backend's own verifier, so the cost + // range gate and the constant-time compare stay in the library. A + // parseable-but-out-of-range cost surfaces as `Err` → `false`, matching + // the old backend's hash-time rejection. + let canonical = format!( + "$2b${:02}${}{}", + parts.cost, + salt_engine().encode(parts.salt), + salt_engine().encode(parts.hash), + ); + bcrypt::verify(password, &canonical).unwrap_or(false) } } @@ -35,10 +103,10 @@ impl Task for VerifyTask { type JsValue = bool; fn compute(&mut self) -> Result { - VerifyTask::verify(self.password.as_ref(), self.hash.as_ref()) + Ok(Self::verify(&self.password, &self.hash)) } - fn resolve(&mut self, _: Env, output: Self::Output) -> Result { + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { Ok(output) } } diff --git a/packages/bcrypt/wasi-worker-browser.mjs b/packages/bcrypt/wasi-worker-browser.mjs index 8b1b1722..7289dd9b 100644 --- a/packages/bcrypt/wasi-worker-browser.mjs +++ b/packages/bcrypt/wasi-worker-browser.mjs @@ -1,4 +1,10 @@ -import { instantiateNapiModuleSync, MessageHandler, WASI } from '@napi-rs/wasm-runtime' +import { + instantiateNapiModuleSync, + MessageHandler, + WASI, + emnapiAsyncWorkPlugin, + emnapiTSFNPlugin, +} from '@napi-rs/wasm-runtime' const handler = new MessageHandler({ onLoad({ wasmModule, wasmMemory }) { @@ -7,7 +13,7 @@ const handler = new MessageHandler({ // eslint-disable-next-line no-console console.log.apply(console, arguments) }, - printErr: function() { + printErr: function () { // eslint-disable-next-line no-console console.error.apply(console, arguments) }, @@ -15,6 +21,11 @@ const handler = new MessageHandler({ return instantiateNapiModuleSync(wasmModule, { childThread: true, wasi, + // The wasm links a "basic" emnapi archive (no C async-work / + // threadsafe-function implementations), so every thread that + // instantiates it must provide the JavaScript implementations + // through the emnapi plugins. + plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin], overwriteImports(importObject) { importObject.env = { ...importObject.env, diff --git a/packages/bcrypt/wasi-worker.mjs b/packages/bcrypt/wasi-worker.mjs index 84b448fc..80c9a4f9 100644 --- a/packages/bcrypt/wasi-worker.mjs +++ b/packages/bcrypt/wasi-worker.mjs @@ -1,17 +1,23 @@ -import fs from "node:fs"; -import { createRequire } from "node:module"; -import { parse } from "node:path"; -import { WASI } from "node:wasi"; -import { parentPort, Worker } from "node:worker_threads"; +import fs from 'node:fs' +import { createRequire } from 'node:module' +import { parse } from 'node:path' +import { WASI } from 'node:wasi' +import { parentPort, Worker, workerData } from 'node:worker_threads' -const require = createRequire(import.meta.url); +const require = createRequire(import.meta.url) -const { instantiateNapiModuleSync, MessageHandler, getDefaultContext } = require("@napi-rs/wasm-runtime"); +const { + instantiateNapiModuleSync, + MessageHandler, + getDefaultContext, + emnapiAsyncWorkPlugin, + emnapiTSFNPlugin, +} = require('@napi-rs/wasm-runtime') if (parentPort) { - parentPort.on("message", (data) => { - globalThis.onmessage({ data }); - }); + parentPort.on('message', (data) => { + globalThis.onmessage({ data }) + }) } Object.assign(globalThis, { @@ -19,18 +25,25 @@ Object.assign(globalThis, { require, Worker, importScripts: function (f) { - ;(0, eval)(fs.readFileSync(f, "utf8") + "//# sourceURL=" + f); + // oxlint-disable-next-line no-eval -- WASI importScripts polyfill + ;(0, eval)(fs.readFileSync(f, 'utf8') + '//# sourceURL=' + f) }, postMessage: function (msg) { if (parentPort) { - parentPort.postMessage(msg); + parentPort.postMessage(msg) } }, -}); +}) -const emnapiContext = getDefaultContext(); +const emnapiContext = getDefaultContext() -const __rootDir = parse(process.cwd()).root; +const __cwd = process.cwd() +const __rootDir = + (workerData && typeof workerData.rootDir === 'string' && workerData.rootDir) || + parse(__cwd).root +const __hostRoot = + (workerData && typeof workerData.hostRoot === 'string' && workerData.hostRoot) || + (process.platform === 'android' ? __cwd : __rootDir) const handler = new MessageHandler({ onLoad({ wasmModule, wasmMemory }) { @@ -38,26 +51,32 @@ const handler = new MessageHandler({ version: 'preview1', env: process.env, preopens: { - [__rootDir]: __rootDir, + [__rootDir]: __hostRoot, + [__hostRoot]: __hostRoot, }, - }); + }) return instantiateNapiModuleSync(wasmModule, { childThread: true, wasi, context: emnapiContext, + // The wasm links a "basic" emnapi archive (no C async-work / + // threadsafe-function implementations), so every thread that + // instantiates it must provide the JavaScript implementations + // through the emnapi plugins. + plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin], overwriteImports(importObject) { importObject.env = { ...importObject.env, ...importObject.napi, ...importObject.emnapi, - memory: wasmMemory - }; + memory: wasmMemory, + } }, - }); + }) }, -}); +}) globalThis.onmessage = function (e) { - handler.handle(e); -}; + handler.handle(e) +} diff --git a/yarn.lock b/yarn.lock index 71839367..3ab85bef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -122,23 +122,23 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.1.0": - version: 1.11.3 - resolution: "@emnapi/core@npm:1.11.3" +"@emnapi/core@npm:2.0.0-alpha.5, @emnapi/core@npm:^2.0.0-alpha.4": + version: 2.0.0-alpha.5 + resolution: "@emnapi/core@npm:2.0.0-alpha.5" dependencies: - "@emnapi/wasi-threads": "npm:1.2.3" + "@emnapi/wasi-threads": "npm:2.1.0" tslib: "npm:^2.4.0" - checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e + checksum: 10c0/c89ae29b699d1b75f555ac18342dfa7354be4ee6ed60c071b1723a7bf2f2f20ed3418e6157ca12e457309e44fc1776e6699f66c65319e6ace56c1c859b42a7b4 languageName: node linkType: hard -"@emnapi/core@npm:^2.0.0-alpha.4": - version: 2.0.0-alpha.5 - resolution: "@emnapi/core@npm:2.0.0-alpha.5" +"@emnapi/core@npm:^1.1.0": + version: 1.11.3 + resolution: "@emnapi/core@npm:1.11.3" dependencies: - "@emnapi/wasi-threads": "npm:2.1.0" + "@emnapi/wasi-threads": "npm:1.2.3" tslib: "npm:^2.4.0" - checksum: 10c0/c89ae29b699d1b75f555ac18342dfa7354be4ee6ed60c071b1723a7bf2f2f20ed3418e6157ca12e457309e44fc1776e6699f66c65319e6ace56c1c859b42a7b4 + checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e languageName: node linkType: hard @@ -169,21 +169,21 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.1.0": - version: 1.11.3 - resolution: "@emnapi/runtime@npm:1.11.3" +"@emnapi/runtime@npm:2.0.0-alpha.5, @emnapi/runtime@npm:^2.0.0-alpha.4": + version: 2.0.0-alpha.5 + resolution: "@emnapi/runtime@npm:2.0.0-alpha.5" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + checksum: 10c0/0effd9cc76cb7d65d38d4fc30b0d4599bd6c96abbfe334d10f7739e69e95f3e465d4243019931a0d7ae682ba42420b0170b87b341ea410e700343f5002704ec9 languageName: node linkType: hard -"@emnapi/runtime@npm:^2.0.0-alpha.4": - version: 2.0.0-alpha.5 - resolution: "@emnapi/runtime@npm:2.0.0-alpha.5" +"@emnapi/runtime@npm:^1.1.0": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/0effd9cc76cb7d65d38d4fc30b0d4599bd6c96abbfe334d10f7739e69e95f3e465d4243019931a0d7ae682ba42420b0170b87b341ea410e700343f5002704ec9 + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 languageName: node linkType: hard @@ -1276,7 +1276,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.6, @napi-rs/wasm-runtime@npm:^1.2.3": +"@napi-rs/wasm-runtime@npm:^1.1.6, @napi-rs/wasm-runtime@npm:^1.2.3, @napi-rs/wasm-runtime@npm:~1.2.3": version: 1.2.4 resolution: "@napi-rs/wasm-runtime@npm:1.2.4" dependencies: @@ -1453,6 +1453,108 @@ __metadata: languageName: unknown linkType: soft +"@node-rs/bcrypt-android-arm-eabi@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-android-arm-eabi@npm:1.10.9" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@node-rs/bcrypt-android-arm64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-android-arm64@npm:1.10.9" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-darwin-arm64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-darwin-arm64@npm:1.10.9" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-darwin-x64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-darwin-x64@npm:1.10.9" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-freebsd-x64@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-freebsd-x64@npm:1.10.9" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-arm-gnueabihf@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-arm-gnueabihf@npm:1.10.9" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-arm64-gnu@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-arm64-gnu@npm:1.10.9" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-arm64-musl@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-arm64-musl@npm:1.10.9" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-x64-gnu@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-x64-gnu@npm:1.10.9" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@node-rs/bcrypt-linux-x64-musl@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-linux-x64-musl@npm:1.10.9" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@node-rs/bcrypt-wasm32-wasi@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-wasm32-wasi@npm:1.10.9" + dependencies: + "@emnapi/core": "npm:2.0.0-alpha.5" + "@emnapi/runtime": "npm:2.0.0-alpha.5" + "@napi-rs/wasm-runtime": "npm:~1.2.3" + checksum: 10c0/9386f894721624af0fbdc1b9ef489396e5bced81a0faf61019d535cf5ccc7858fb8716bf610ec6a7daaf357429e8d7e5dc211efe700aead6e3a9c10478e6e825 + languageName: node + linkType: hard + +"@node-rs/bcrypt-win32-arm64-msvc@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-win32-arm64-msvc@npm:1.10.9" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/bcrypt-win32-ia32-msvc@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-win32-ia32-msvc@npm:1.10.9" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@node-rs/bcrypt-win32-x64-msvc@npm:1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt-win32-x64-msvc@npm:1.10.9" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@node-rs/bcrypt@workspace:packages/bcrypt": version: 0.0.0-use.local resolution: "@node-rs/bcrypt@workspace:packages/bcrypt" @@ -1460,9 +1562,12 @@ __metadata: "@cwasm/openbsd-bcrypt": "npm:^0.1.0" "@cwasm/openwall-bcrypt": "npm:^0.1.0" "@napi-rs/cli": "npm:^3.8.6" + "@node-rs/bcrypt-wasm32-wasi": "npm:1.10.9" "@types/bcrypt": "npm:^6.0.0" "@types/bcryptjs": "npm:^3.0.0" + abort-controller: "npm:3.0.0" bcrypt: "npm:^6.0.0" + bcrypt-previous: "npm:@node-rs/bcrypt@1.10.9" bcryptjs: "npm:^3.0.3" cross-env: "npm:^10.1.0" tinybench: "npm:^6.1.3" @@ -3239,6 +3344,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "acorn-import-attributes@npm:^1.9.5": version: 1.9.5 resolution: "acorn-import-attributes@npm:1.9.5" @@ -3555,6 +3669,54 @@ __metadata: languageName: node linkType: hard +"bcrypt-previous@npm:@node-rs/bcrypt@1.10.9": + version: 1.10.9 + resolution: "@node-rs/bcrypt@npm:1.10.9" + dependencies: + "@node-rs/bcrypt-android-arm-eabi": "npm:1.10.9" + "@node-rs/bcrypt-android-arm64": "npm:1.10.9" + "@node-rs/bcrypt-darwin-arm64": "npm:1.10.9" + "@node-rs/bcrypt-darwin-x64": "npm:1.10.9" + "@node-rs/bcrypt-freebsd-x64": "npm:1.10.9" + "@node-rs/bcrypt-linux-arm-gnueabihf": "npm:1.10.9" + "@node-rs/bcrypt-linux-arm64-gnu": "npm:1.10.9" + "@node-rs/bcrypt-linux-arm64-musl": "npm:1.10.9" + "@node-rs/bcrypt-linux-x64-gnu": "npm:1.10.9" + "@node-rs/bcrypt-linux-x64-musl": "npm:1.10.9" + "@node-rs/bcrypt-win32-arm64-msvc": "npm:1.10.9" + "@node-rs/bcrypt-win32-ia32-msvc": "npm:1.10.9" + "@node-rs/bcrypt-win32-x64-msvc": "npm:1.10.9" + dependenciesMeta: + "@node-rs/bcrypt-android-arm-eabi": + optional: true + "@node-rs/bcrypt-android-arm64": + optional: true + "@node-rs/bcrypt-darwin-arm64": + optional: true + "@node-rs/bcrypt-darwin-x64": + optional: true + "@node-rs/bcrypt-freebsd-x64": + optional: true + "@node-rs/bcrypt-linux-arm-gnueabihf": + optional: true + "@node-rs/bcrypt-linux-arm64-gnu": + optional: true + "@node-rs/bcrypt-linux-arm64-musl": + optional: true + "@node-rs/bcrypt-linux-x64-gnu": + optional: true + "@node-rs/bcrypt-linux-x64-musl": + optional: true + "@node-rs/bcrypt-win32-arm64-msvc": + optional: true + "@node-rs/bcrypt-win32-ia32-msvc": + optional: true + "@node-rs/bcrypt-win32-x64-msvc": + optional: true + checksum: 10c0/e787d4cee0c8d4ee499fdb080d2439ed5851c40757384d370035b3c8cfbd91fe58d6e79f4828613544f00fdba500a28cbb7b33bbb61449ba2eb44d1876513ef2 + languageName: node + linkType: hard + "bcrypt@npm:^6.0.0": version: 6.0.0 resolution: "bcrypt@npm:6.0.0" @@ -4608,6 +4770,13 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7"