From 38c7b99cd8168c80b352d357c23d03f5069adcaf Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Thu, 28 May 2026 16:05:13 -0700 Subject: [PATCH 1/2] Fix string-to-integer coercion in query matching Addresses #74 --- lib/query/dcql.js | 4 +- lib/query/match.js | 8 +-- lib/query/util.js | 12 ++-- tests/unit/mockCredentials.js | 22 +++++++ tests/unit/query.match.spec.js | 101 +++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 12 deletions(-) diff --git a/lib/query/dcql.js b/lib/query/dcql.js index abeff31..a388bd9 100644 --- a/lib/query/dcql.js +++ b/lib/query/dcql.js @@ -2,7 +2,7 @@ * Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved. */ import { - fromJsonPointerMap, isNumber, toJsonPointerMap, toNumberIfNumber + fromJsonPointerMap, isNumber, toIntegerIfInteger, toJsonPointerMap } from './util.js'; import {exampleToJsonPointerMap} from './queryByExample.js'; import jsonpointer from 'json-pointer'; @@ -147,7 +147,7 @@ export function _fromQueryByExampleQuery({ const pathsMap = new Map(); for(const [pointer, value] of pointers) { // parse path into DCQL path w/ numbers for array indexes - let path = jsonpointer.parse(pointer).map(toNumberIfNumber); + let path = jsonpointer.parse(pointer).map(toIntegerIfInteger); // special process non-`@context` paths to convert some array indexes // to DCQL `null` (which means "any" index) diff --git a/lib/query/match.js b/lib/query/match.js index 6ae13de..0a21896 100644 --- a/lib/query/match.js +++ b/lib/query/match.js @@ -1,7 +1,7 @@ /*! - * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2025-2026 Digital Bazaar, Inc. All rights reserved. */ -import {isObject, resolvePointer, toNumberIfNumber} from './util.js'; +import {isObject, resolvePointer, toIntegerIfInteger} from './util.js'; /** * Returns whether a credential matches against a JSON pointer map. @@ -66,8 +66,8 @@ function _match({cursor, matchValue, options}) { // string/number coercion if(options.coerceNumbers) { - const cursorNumber = toNumberIfNumber(cursor); - const matchNumber = toNumberIfNumber(matchValue); + const cursorNumber = toIntegerIfInteger(cursor); + const matchNumber = toIntegerIfInteger(matchValue); return cursorNumber !== undefined && cursorNumber === matchNumber; } diff --git a/lib/query/util.js b/lib/query/util.js index 0ab72a8..edebb13 100644 --- a/lib/query/util.js +++ b/lib/query/util.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2022-2026 Digital Bazaar, Inc. All rights reserved. */ import {assert} from '../util.js'; import jsonpointer from 'json-pointer'; @@ -10,7 +10,7 @@ export function fromJsonPointerMap({map} = {}) { } export function isNumber(x) { - return typeof toNumberIfNumber(x) === 'number'; + return typeof toIntegerIfInteger(x) === 'number'; } export function isObject(x) { @@ -37,13 +37,13 @@ export function toJsonPointerMap({obj, flat = false} = {}) { return _toPointers({cursor: obj, map: new Map(), flat}); } -export function toNumberIfNumber(x) { +export function toIntegerIfInteger(x) { if(typeof x === 'number') { return x; } - const num = parseInt(x, 10); - if(!isNaN(num)) { - return num; + if(typeof x === 'string') { + const i = parseInt(x, 10); + return i.toString() === x ? i : x; } return x; } diff --git a/tests/unit/mockCredentials.js b/tests/unit/mockCredentials.js index 54ee334..a341bde 100644 --- a/tests/unit/mockCredentials.js +++ b/tests/unit/mockCredentials.js @@ -269,5 +269,27 @@ export const edgeCaseCredentials = [ }] ] } + }, + + // Credential with numeric-like strings (integer-coercion regression) + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://www.w3.org/ns/credentials/examples/v2' + ], + type: ['VerifiableCredential', 'ScoreCredential'], + credentialSubject: { + id: 'did:example:integer-coercion', + name: 'Integer Coercion Person', + // decimal string: must NOT be truncated to an integer + score: '3.14', + // partially-numeric string: trailing chars must NOT be ignored + value: '21abc', + // valid integer string: must STILL coerce + count: '21' + }, + issuer: { + id: 'did:example:issuer' + } } ]; diff --git a/tests/unit/query.match.spec.js b/tests/unit/query.match.spec.js index 71d1569..07e0a7c 100644 --- a/tests/unit/query.match.spec.js +++ b/tests/unit/query.match.spec.js @@ -618,6 +618,107 @@ describe('query.match', () => { // should match the credential with yearOfBirth: 1998 (number) expect(matches).to.have.length(1); }); + + it('should NOT coerce a decimal string to an integer', function() { + const queryByExample = { + example: { + credentialSubject: { + // integer query must not match the string '3.14' + score: 3 + } + } + }; + + const matches = _matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample + }); + + // '3.14' must not be truncated to 3 + expect(matches).to.have.length(0); + }); + + it('should NOT coerce a partially-numeric string to an integer', + function() { + const queryByExample = { + example: { + credentialSubject: { + // integer query must not match the string '21abc' + value: 21 + } + } + }; + + const matches = _matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample + }); + + // '21abc' must not have its trailing characters ignored + expect(matches).to.have.length(0); + }); + + it('should still coerce a valid integer', function() { + const queryByExample = { + example: { + credentialSubject: { + // integer query must still match the string '21' + count: 21 + } + } + }; + + const matches = _matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample + }); + + // valid integer strings continue to coerce + expect(matches).to.have.length(1); + expect(matches[0].credentialSubject.name) + .to.equal('Integer Coercion Person'); + }); + + // NOTE: decimal strings are deliberately left uncoerced ("integer only" + // coercion); this test documents and locks in that behavior so a future + // switch to numeric coercion (e.g., `Number()`) is caught. + it('should NOT coerce decimals, only match decimal strings exactly', + function() { + // a decimal written as a NUMBER must not match the string '3.14' + const numberQuery = { + example: { + credentialSubject: { + score: 3.14 + } + } + }; + + const numberMatches = _matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample: numberQuery + }); + + // '3.14' (string) is not coerced, so it does not equal 3.14 (number) + expect(numberMatches).to.have.length(0); + + // a decimal written as a STRING matches the identical string directly + const stringQuery = { + example: { + credentialSubject: { + score: '3.14' + } + } + }; + + const stringMatches = _matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample: stringQuery + }); + + expect(stringMatches).to.have.length(1); + expect(stringMatches[0].credentialSubject.name) + .to.equal('Integer Coercion Person'); + }); }); describe('Real-world Scenarios', function() { From 0e22507160bd336215888951587a41b2d97ffe3d Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 5 Jun 2026 13:35:07 -0400 Subject: [PATCH 2/2] Remove extra number type check. Co-authored-by: David I. Lehn --- lib/query/util.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/query/util.js b/lib/query/util.js index edebb13..e639fa6 100644 --- a/lib/query/util.js +++ b/lib/query/util.js @@ -38,9 +38,6 @@ export function toJsonPointerMap({obj, flat = false} = {}) { } export function toIntegerIfInteger(x) { - if(typeof x === 'number') { - return x; - } if(typeof x === 'string') { const i = parseInt(x, 10); return i.toString() === x ? i : x;