Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions packages/js/src/core/dot-notation-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,17 +343,38 @@ export class DotNotationParser implements ValidatableParserInterface {
*/
private segmentPathCache(path: string): Segment[] {
if (this.pathCache !== null) {
const cached = this.pathCache.get(path);
const cacheKey = this.normalizeCacheKey(path);
const cached = this.pathCache.get(cacheKey);
if (cached !== null) {
return cached;
}
const segments = this.segmentParser.parseSegments(path);
this.pathCache.set(path, segments);
this.pathCache.set(cacheKey, segments);
return segments;
}
return this.segmentParser.parseSegments(path);
}

/**
* Normalize a path to its cache key by stripping the optional root prefix.
*
* The segment parser ignores a leading `$` (and the `.` that may follow),
* so `$.a.b`, `$a.b`, and `a.b` parse identically. Collapsing them to one
* cache key avoids storing duplicate entries for equivalent paths.
*
* @param path - Dot-notation path string.
* @returns Normalized cache key.
*/
private normalizeCacheKey(path: string): string {
if (path[0] === '$') {
path = path.slice(1);
if (path[0] === '.') {
path = path.slice(1);
}
}
return path;
}

/**
* Recursively write a value at the given key path.
*
Expand Down
80 changes: 77 additions & 3 deletions packages/js/src/parser/yaml-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export class YamlParser {
`YAML merge keys (<<) are not supported (line ${i + 1}).`,
);
}

// Also block merge keys used as flow-map keys ("{<<:" or ", <<:"),
// which the line-start check above would miss. The negative
// lookbehind keeps the match in genuine key position and out of
// adjacent quoted regions; rejection is fail-closed.
if (/(?<!['"])[{,]\s*<<\s*:/.test(rawLine)) {
throw new YamlParseException(
`YAML merge keys (<<) are not supported (line ${i + 1}).`,
);
}
}
}

Expand Down Expand Up @@ -405,6 +415,11 @@ export class YamlParser {
/**
* Parse a YAML flow map ({a: b, c: d}) into a record.
*
* Values are scalars only: nested flow collections (e.g. {a: {b: 1}} or
* {a: [1, 2]}) are not expanded and are kept as their raw string value.
* This is an intentional limitation of the minimal parser and is mirrored
* in the PHP implementation for behavioral parity.
*
* @param value - Raw flow map string including braces.
* @returns Parsed key-value pairs.
*/
Expand All @@ -418,18 +433,77 @@ export class YamlParser {
const items = this.splitFlowItems(inner);
for (const item of items) {
const trimmedItem = item.trim();
const colonPos = trimmedItem.indexOf(':');
if (colonPos === -1) {
const colonPos = this.findFlowColon(trimmedItem);
if (colonPos < 0) {
continue;
}
const key = trimmedItem.substring(0, colonPos).trim();
const key = this.unquoteKey(trimmedItem.substring(0, colonPos).trim());
const val = trimmedItem.substring(colonPos + 1).trim();
result[key] = this.castScalar(val);
}

return result;
}

/**
* Find the first colon outside of quoted regions in a flow-map item.
*
* A naive search would split on a colon inside a quoted key
* (e.g. {"a:b": v}), corrupting both key and value.
*
* @param item - Single flow-map item (key/value pair).
* @returns Index of the separating colon, or -1 if none is found.
*/
private findFlowColon(item: string): number {
let inQuote = false;
let quoteChar = '';

for (let i = 0; i < item.length; i++) {
const ch = item[i];

if (inQuote) {
if (ch === quoteChar) {
inQuote = false;
}
continue;
}

if (ch === '"' || ch === "'") {
inQuote = true;
quoteChar = ch;
continue;
}

if (ch === ':') {
return i;
}
}

return -1;
}

/**
* Strip a single matching pair of surrounding quotes from a flow-map key.
*
* Keys are always strings, so no scalar casting is applied; only the
* outer quotes are removed (and doubled single-quotes unescaped).
*
* @param key - Raw flow-map key, possibly quoted.
* @returns Unquoted key.
*/
private unquoteKey(key: string): string {
if (key.length >= 2) {
if (key.startsWith('"') && key.endsWith('"')) {
return this.unescapeDoubleQuoted(key.slice(1, -1));
}
if (key.startsWith("'") && key.endsWith("'")) {
return key.slice(1, -1).replace(/''/g, "'");
}
}

return key;
}

/**
* Split flow-syntax items by comma, respecting nested brackets and quotes.
*
Expand Down
72 changes: 57 additions & 15 deletions packages/js/src/path-query/segment-filter-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,40 @@ export class SegmentFilterParser implements FilterEvaluatorInterface {
return raw.substring(1, raw.length - 1);
}

if (!isNaN(Number(raw)) && raw !== '') {
return raw.includes('.') ? parseFloat(raw) : parseInt(raw, 10);
return this.numericLiteral(raw) ?? raw;
}

/**
* Coerce a raw token to a number using a runtime-agnostic rule.
*
* Only plain decimal integers and decimal floats (including scientific
* notation) are treated as numbers. Hex (`0x`), binary (`0b`), octal
* (`0o`), and underscore-grouped literals are intentionally left as
* strings so PHP and JS produce identical results for untrusted input.
*
* The branch is chosen by which pattern matched (not by the presence of a
* dot), so `1e3` parses as the number 1000. JS has a single number type,
* so integral and fractional values are both plain numbers — this mirrors
* the PHP side, which collapses integral values (1e3 → int 1000) so they
* compare equal to integer data under strict equality.
*
* @param raw - Raw token.
* @returns The number, or null when the token is not numeric.
*/
private numericLiteral(raw: string): number | null {
// Plain decimal integer (handled first so the float branch below
// always carries a dot or exponent).
if (/^[+-]?\d+$/.test(raw)) {
return parseInt(raw, 10);
}

// Decimal float (with a dot, optional exponent) or an integer mantissa
// with a mandatory exponent (e.g. 1e3). Hex/binary/octal never match.
if (/^[+-]?(?:(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?|\d+[eE][+-]?\d+)$/.test(raw)) {
return parseFloat(raw);
}

return raw;
return null;
}

/**
Expand All @@ -239,19 +268,29 @@ export class SegmentFilterParser implements FilterEvaluatorInterface {

const expected = condition.value;

if (condition.operator === '==') {
return fieldValue === expected;
}
if (condition.operator === '!=') {
return fieldValue !== expected;
}

// Relational operators only compare two numbers. Any other type
// combination yields false, keeping PHP and JS identical (PHP's
// native mixed-type comparison and JS coercion diverge otherwise).
if (typeof fieldValue !== 'number' || typeof expected !== 'number') {
return false;
}

switch (condition.operator) {
case '==':
return fieldValue === expected;
case '!=':
return fieldValue !== expected;
case '>':
return (fieldValue as number) > (expected as number);
return fieldValue > expected;
case '<':
return (fieldValue as number) < (expected as number);
return fieldValue < expected;
case '>=':
return (fieldValue as number) >= (expected as number);
return fieldValue >= expected;
case '<=':
return (fieldValue as number) <= (expected as number);
return fieldValue <= expected;
default:
return false;
}
Expand Down Expand Up @@ -355,8 +394,11 @@ export class SegmentFilterParser implements FilterEvaluatorInterface {
}

const toNumber = (token: string): number | null => {
if (!token.startsWith('@') && !isNaN(Number(token)) && token !== '') {
return token.includes('.') ? parseFloat(token) : parseInt(token, 10);
if (!token.startsWith('@')) {
const literal = this.numericLiteral(token);
if (literal !== null) {
return literal;
}
}

const val = this.resolveFilterArg(item, token);
Expand All @@ -365,8 +407,8 @@ export class SegmentFilterParser implements FilterEvaluatorInterface {
return val;
}

if (typeof val === 'string' && !isNaN(Number(val)) && val !== '') {
return val.includes('.') ? parseFloat(val) : parseInt(val, 10);
if (typeof val === 'string') {
return this.numericLiteral(val);
}

return null;
Expand Down
14 changes: 14 additions & 0 deletions packages/js/tests/core/dot-notation-parser-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ describe(`${DotNotationParser.name} > pathCache integration`, () => {
const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser());
expect(parser.get({ a: { b: 1 } }, 'a.b')).toBe(1);
});

it('shares one cache entry across equivalent root-prefixed paths', () => {
const cache = new FakePathCache();
const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser(), cache);
const data = { a: { b: 1 } };

parser.get(data, 'a.b');
parser.get(data, '$.a.b');
parser.get(data, '$a.b');

// All three normalize to the key 'a.b': parsed once, one cache entry.
expect(cache.setCallCount).toBe(1);
expect([...cache.store.keys()]).toEqual(['a.b']);
});
});

// Additional branch-coverage tests (targeting Stryker survivors)
Expand Down
31 changes: 31 additions & 0 deletions packages/js/tests/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,37 @@ describe(`${Inline.name} > PathQuery > filter (parity)`, () => {
});
expect(accessor.get("items[?contains(@.tag, 'world')].tag")).toEqual(['hello-world']);
});

it('matches integer data with a scientific-notation literal (1e3 == 1000)', () => {
const accessor = Inline.fromArray({ items: [{ v: 1000 }, { v: 1 }] });
expect(accessor.get('items[?v == 1e3].v')).toEqual([1000]);
});

it('treats a hex literal as a string, matching only string data (0x1A)', () => {
const accessor = Inline.fromArray({ items: [{ v: '0x1A' }, { v: 26 }] });
expect(accessor.get('items[?v == 0x1A].v')).toEqual(['0x1A']);
});

it('excludes a non-numeric string from a > comparison', () => {
const accessor = Inline.fromArray({ items: [{ v: 'abc' }, { v: 10 }] });
expect(accessor.get('items[?v > 5].v')).toEqual([10]);
});

it('excludes a numeric string from a > comparison', () => {
const accessor = Inline.fromArray({ items: [{ v: '10' }, { v: 20 }] });
expect(accessor.get('items[?v > 5].v')).toEqual([20]);
});
});

describe(`${Inline.name} > PathQuery > YAML flow map (parity)`, () => {
it('splits a quoted flow-map key on the first colon outside quotes', () => {
const accessor = Inline.fromYaml('data: {"key:with:colons": value}');
expect(accessor.get('data')).toEqual({ 'key:with:colons': 'value' });
});

it('rejects a merge key used inside a flow map', () => {
expect(() => Inline.fromYaml('data: {<<: {a: 1}, b: 2}')).toThrow();
});
});

describe(`${Inline.name} > PathQuery > multi-key and multi-index (parity)`, () => {
Expand Down
59 changes: 59 additions & 0 deletions packages/js/tests/parser/yaml-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,53 @@ describe(`${YamlParser.name} > inline flow`, () => {
const result = makeParser().parse(yaml);
expect(result['tags']).toBe('[a b c');
});

it('splits on the first colon outside quotes for a double-quoted flow-map key', () => {
// findFlowColon: a colon inside a quoted key must not split the pair
const yaml = 'data: {"key:with:colons": value}';
const result = makeParser().parse(yaml);
expect(result['data']).toEqual({ 'key:with:colons': 'value' });
});

it('unquotes a single-quoted flow-map key containing a colon', () => {
const yaml = "data: {'a:b': v}";
const result = makeParser().parse(yaml);
expect(result['data']).toEqual({ 'a:b': 'v' });
});

it('keeps a flow-map value containing a colon (URL) intact', () => {
// Regression: the first colon (after url) is outside quotes
const yaml = 'm: {url: http://x}';
const result = makeParser().parse(yaml);
expect(result['m']).toEqual({ url: 'http://x' });
});

it('treats a leading colon in a flow-map item as an empty key', () => {
// findFlowColon returns 0; the empty key is kept (colonPos < 0 guard)
const yaml = 'd: {: value}';
const result = makeParser().parse(yaml);
expect(result['d']).toEqual({ '': 'value' });
});

it('unquotes an empty double-quoted flow-map key', () => {
// unquoteKey strips a 2-char quoted key ("") to an empty string
const yaml = 'd: {"": v}';
const result = makeParser().parse(yaml);
expect(result['d']).toEqual({ '': 'v' });
});

it('unescapes doubled single quotes inside a single-quoted flow-map key', () => {
const yaml = "d: {'a''b': v}";
const result = makeParser().parse(yaml);
expect(result['d']).toEqual({ "a'b": 'v' });
});

it('ignores a flow-map item whose only colon is inside an unterminated quote', () => {
// The colon sits inside the quoted region, so no separator is found
const yaml = 'd: {"ab: v}';
const result = makeParser().parse(yaml);
expect(result['d']).toEqual({});
});
});

describe(`${YamlParser.name} > security - unsafe constructs`, () => {
Expand Down Expand Up @@ -232,6 +279,18 @@ describe(`${YamlParser.name} > security - unsafe constructs`, () => {
expect(() => makeParser().parse('note: use <<: syntax')).not.toThrow();
});

it('throws YamlParseException for a merge key used as a flow-map key', () => {
expect(() => makeParser().parse('data: {<<: {a: 1}, b: 2}')).toThrow(/merge key/i);
});

it('throws YamlParseException for a merge key as a later flow-map key', () => {
expect(() => makeParser().parse('data: {a: 1, <<: {b: 2}}')).toThrow(/merge key/i);
});

it('does not throw for an ordinary flow map without merge keys', () => {
expect(() => makeParser().parse('data: {a: 1, b: 2}')).not.toThrow();
});

it('does not throw for ! inside double-quoted string', () => {
// quoted string - regex should not match
expect(() => makeParser().parse('msg: "hello world"')).not.toThrow();
Expand Down
Loading
Loading