Skip to content

Fix: Reject out-of-range keys in JavaScript bindings - #812

Merged
ashvardanian merged 1 commit into
unum-cloud:main-devfrom
OsamaAnsar:fix/js-validate-keys
Oct 24, 2026
Merged

ashvardanian merged 1 commit into
unum-cloud:main-devfrom
OsamaAnsar:fix/js-validate-keys

Conversation

@OsamaAnsar

Copy link
Copy Markdown
Contributor

normalizeKeys only range-checks keys that are passed inside an array (number >= 0). A single number and every bigint go straight to BigUint64Array.of(BigInt(keys)), which silently wraps modulo 2^64:

const idx = new Index({ dimensions: 3, metric: "cos" });
idx.add(-1, new Float32Array([0, 0, 1]));        // accepted
idx.contains(2n ** 64n - 1n);                    // true  (stored as 2^64 - 1)

idx.add(2n ** 64n, new Float32Array([0, 0, 1])); // accepted
idx.contains(0n);                                // true  (stored as 0)

It also affects remove: in the published usearch@2.26.5, with the key 2n ** 64n - 1n in the index, idx.remove(-1) returns 1 and deletes that unrelated vector. Meanwhile the array form idx.add([-1], ...) already throws All keys must be positive integers or bigints., so the scalar and array paths disagree.

This is input validation: invalid keys are rejected the same way on every path instead of being stored under a different key.

Change

  • New normalizeKey helper used by the scalar and the array path. A key must be a bigint or an integer number and must be within [0, 2^64) (BigInt.asUintN(64, key) === key).
  • Non-integer / non-finite numbers throw All keys must be positive integers or bigints. on both paths (the scalar path used to leak a bare RangeError from BigInt(1.5)). Out-of-range keys throw All keys must be in the range [0, 2^64).
  • A scalar NaN still reports the existing Keys must be a number, bigint, ... error.
  • contains, count and remove go through the same normalizeKeys, so they now throw for such keys instead of looking up / removing the wrapped key. BigUint64Array input is untouched.
  • Valid keys are unaffected, including 0n, 2n ** 64n - 1n and plain number keys like index.contains(15).

Before / after

Published usearch@2.26.5 prebuilt binary, Windows x64, Node 24, fresh index with 2 vectors:

call before after
idx.add(-1, v), then idx.contains(2n**64n-1n) accepted, size 3, true Error: All keys must be in the range [0, 2^64).
idx.add(-1n, v) accepted, stored as 2^64 - 1 same error
idx.add(2n**64n, v), then idx.contains(0n) accepted, size 3, true same error
idx.add([2n**64n], v) accepted same error
idx.add([-1], v) All keys must be positive integers or bigints. All keys must be in the range [0, 2^64).
idx.contains(-1) / idx.contains(2n**64n) false (wrapped lookup) same error
idx.add(1.5, v) / idx.contains(1.5) RangeError: The number 1.5 cannot be converted to a BigInt ... Error: All keys must be positive integers or bigints.
idx.add(2n**64n - 1n, v) works works

Tests and what I could (not) run

New test Keys outside of the unsigned 64-bit range in javascript/usearch.test.js: for -1, -1n, -(2n**63n), 2n**64n, 2n**64n + 1n and 1e20 it checks that add (scalar and in an array) throws and leaves the index empty, that contains / count / remove throw and do not remove existing keys, and that the boundaries 0n, 2n**64n - 1n and Number.MAX_SAFE_INTEGER are still accepted.

I have no C++ toolchain, so I compiled javascript/usearch.ts with tsc (CJS config) and ran javascript/usearch.test.js against the prebuilds/ from the published usearch@2.26.5 npm package (Windows x64 only):

  • With the change: 51 tests, 48 pass, 0 fail, 3 skipped (existing view tests).
  • Only javascript/usearch.ts reverted to main-dev, new test kept: 18 failures, all in the new test (the bad keys are accepted instead of rejected).
  • Baseline main-dev: 31 tests, 28 pass, 3 skipped.

Not tested: Linux / macOS / arm64 binaries, building the addon from source, the ESM output.

Behaviour note: contains(-1) and friends used to return false (or, worse, match a wrapped key); they now throw, consistent with contains([-1]) already doing so.

`normalizeKeys` only range-checked keys passed inside an array. A single
`number` or any `bigint` went straight into `BigUint64Array.of(...)`,
which wraps modulo 2^64. As a result `index.add(-1, ...)` stored the
vector under 2^64 - 1, `index.add(2n ** 64n, ...)` stored it under 0, and
`index.remove(-1)` deleted whatever was stored under 2^64 - 1.

Check that every key, whether scalar or inside an array, is an integer
within [0, 2^64) and throw otherwise, like the array path already did for
negative numbers. Non-integer numbers now throw the same error instead of
a bare `RangeError` from `BigInt()`.
@ashvardanian
ashvardanian merged commit b65a420 into unum-cloud:main-dev Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants