UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

622 lines 20.8 kB
"use strict"; /** * A minimal, safe JSONPath evaluator that replaces the `jsonpath` npm package. * * The `jsonpath` library (v1.2.1) has CVE GHSA-87r5-mp6g-5w5j due to its use * of `eval()` for script expressions. This implementation covers the subset of * JSONPath used by this project with NO dynamic code execution. * * Supported features: * - Property access: $.user.name * - Array indexing (positive & negative): $.calls[0], $.calls[-1] * - Slice notation: $.calls[-2:], $.calls[1:], $.calls[0:2] * - Filter expressions: $.books[?(@.price < 10)] * - Logical AND in filters: [?(@.a < 5 && @.b == "x")] * - Property existence filters: [?(@.isbn)] * - Filter + index: $.books[?(@.price < 10)][0] * - Recursive descent: $..author * - Wildcards: $[*], $..books[*].title */ Object.defineProperty(exports, "__esModule", { value: true }); exports.queryJsonPath = queryJsonPath; // --------------------------------------------------------------------------- // Tokenizer // --------------------------------------------------------------------------- function tokenize(expression) { const tokens = []; let i = 0; if (expression[i] !== '$') { throw new Error(`JSONPath must start with $, got: ${expression}`); } tokens.push({ type: 'root' }); i++; while (i < expression.length) { if (expression[i] === '.') { if (expression[i + 1] === '.') { tokens.push({ type: 'dotdot' }); i += 2; } else { tokens.push({ type: 'dot' }); i++; } } else if (expression[i] === '[') { i++; // skip '[' if (expression[i] === '?' && expression[i + 1] === '(') { // Filter expression: find matching closing )] i += 2; // skip '?(' let depth = 1; let filterStart = i; while (i < expression.length && depth > 0) { if (expression[i] === '(') { depth++; } else if (expression[i] === ')') { depth--; } if (depth > 0) { i++; } } if (depth !== 0) { throw new Error(`Unmatched filter parenthesis in: ${expression}`); } const raw = expression.substring(filterStart, i); i++; // skip ')' if (expression[i] !== ']') { throw new Error(`Expected ] after filter expression in: ${expression}`); } i++; // skip ']' tokens.push({ type: 'filter', raw }); } else if (expression[i] === '*') { i++; // skip '*' if (expression[i] !== ']') { throw new Error(`Expected ] after * in: ${expression}`); } i++; // skip ']' tokens.push({ type: 'wildcard' }); } else { // Index or slice: read until ']' const bracketStart = i; while (i < expression.length && expression[i] !== ']') { i++; } if (i >= expression.length) { throw new Error(`Unmatched [ in: ${expression}`); } const content = expression.substring(bracketStart, i); i++; // skip ']' if (content.includes(':')) { // Slice notation const parts = content.split(':'); const start = parts[0] !== '' ? parseInt(parts[0], 10) : undefined; const end = parts[1] !== '' ? parseInt(parts[1], 10) : undefined; if ((parts[0] !== '' && isNaN(start)) || (parts[1] !== '' && isNaN(end))) { throw new Error(`Invalid slice notation: [${content}]`); } tokens.push({ type: 'slice', start, end }); } else { // Integer index const value = parseInt(content, 10); if (isNaN(value)) { throw new Error(`Invalid bracket expression: [${content}]`); } tokens.push({ type: 'index', value }); } } } else if (expression[i] === '*') { tokens.push({ type: 'wildcard' }); i++; } else if (isIdentStart(expression[i])) { const start = i; while (i < expression.length && isIdentChar(expression[i])) { i++; } tokens.push({ type: 'property', name: expression.substring(start, i) }); } else { throw new Error(`Unexpected character '${expression[i]}' at position ${i} in: ${expression}`); } } return tokens; } function isIdentStart(ch) { return /[a-zA-Z_]/.test(ch); } function isIdentChar(ch) { return /[a-zA-Z0-9_-]/.test(ch); } // --------------------------------------------------------------------------- // Filter expression parser // --------------------------------------------------------------------------- const COMPARISON_OPS = [ '!==', '===', '!=', '==', '<=', '>=', '<', '>', ]; function parseFilterCondition(raw) { // Split on && (top-level only, respecting quotes) const parts = splitOnLogicalAnd(raw); if (parts.length > 1) { return { kind: 'and', conditions: parts.map((p) => parseSingleCondition(p.trim())), }; } return parseSingleCondition(raw.trim()); } function splitOnLogicalAnd(raw) { const parts = []; let current = ''; let inString = null; let depth = 0; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (inString) { current += ch; if (ch === inString && raw[i - 1] !== '\\') { inString = null; } continue; } if (ch === '"' || ch === "'") { inString = ch; current += ch; continue; } if (ch === '(') { depth++; current += ch; continue; } if (ch === ')') { depth--; current += ch; continue; } if (depth === 0 && ch === '&' && raw[i + 1] === '&') { parts.push(current); current = ''; i++; // skip second & continue; } current += ch; } parts.push(current); return parts; } function parseSingleCondition(expr) { // Try to find a comparison operator for (const op of COMPARISON_OPS) { const idx = findOperatorIndex(expr, op); if (idx !== -1) { const left = expr.substring(0, idx).trim(); const right = expr.substring(idx + op.length).trim(); return { kind: 'comparison', left: parseOperand(left), op, right: parseOperand(right), }; } } // No operator found — existence check if (expr.startsWith('@.')) { return { kind: 'existence', segments: expr.substring(2).split('.') }; } throw new Error(`Cannot parse filter condition: ${expr}`); } function findOperatorIndex(expr, op) { let inString = null; for (let i = 0; i < expr.length - op.length + 1; i++) { const ch = expr[i]; if (inString) { if (ch === inString && expr[i - 1] !== '\\') { inString = null; } continue; } if (ch === '"' || ch === "'") { inString = ch; continue; } if (expr.substring(i, i + op.length) === op) { // For single-char ops (< > = !), make sure we're not part of a multi-char op if (op.length === 1) { const next = expr[i + 1]; if (next === '=' || (op === '!' && next === '=')) { continue; } } if (op.length === 2 && (op === '==' || op === '!=')) { const next = expr[i + 2]; if (next === '=') { continue; } } return i; } } return -1; } function parseOperand(raw) { const trimmed = raw.trim(); // @.path if (trimmed.startsWith('@.')) { return { kind: 'path', segments: trimmed.substring(2).split('.') }; } // String literal if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { return { kind: 'literal', value: trimmed.slice(1, -1) }; } // Boolean if (trimmed === 'true') { return { kind: 'literal', value: true }; } if (trimmed === 'false') { return { kind: 'literal', value: false }; } // Null if (trimmed === 'null') { return { kind: 'literal', value: null }; } // Number const num = Number(trimmed); if (!isNaN(num) && trimmed !== '') { return { kind: 'literal', value: num }; } throw new Error(`Cannot parse filter operand: ${raw}`); } // --------------------------------------------------------------------------- // Parser: tokens → path segments // --------------------------------------------------------------------------- function parse(tokens) { const segments = []; let i = 0; while (i < tokens.length) { const tok = tokens[i]; switch (tok.type) { case 'root': segments.push({ type: 'root' }); i++; break; case 'dot': // Next token should be a property or wildcard i++; break; case 'dotdot': { // Recursive descent — wrap the next meaningful segment i++; const next = tokens[i]; if (!next) { throw new Error('Unexpected end after ..'); } if (next.type === 'property') { segments.push({ type: 'recursiveDescent', target: { type: 'property', name: next.name }, }); } else if (next.type === 'wildcard') { segments.push({ type: 'recursiveDescent', target: { type: 'wildcard' }, }); } else if (next.type === 'filter') { segments.push({ type: 'recursiveDescent', target: { type: 'filter', condition: parseFilterCondition(next.raw), }, }); } else { throw new Error(`Unexpected token after ..: ${next.type}`); } i++; break; } case 'property': segments.push({ type: 'property', name: tok.name }); i++; break; case 'index': segments.push({ type: 'index', value: tok.value }); i++; break; case 'slice': segments.push({ type: 'slice', start: tok.start, end: tok.end }); i++; break; case 'wildcard': segments.push({ type: 'wildcard' }); i++; break; case 'filter': { const condition = parseFilterCondition(tok.raw); // Check if next token is an index — combine into filterThenIndex if (i + 1 < tokens.length && tokens[i + 1].type === 'index') { const idxTok = tokens[i + 1]; segments.push({ type: 'filterThenIndex', condition, index: idxTok.value, }); i += 2; } else { segments.push({ type: 'filter', condition }); i++; } break; } } } return segments; } // --------------------------------------------------------------------------- // Evaluator // --------------------------------------------------------------------------- function evaluate(data, segments) { let current = []; for (const seg of segments) { switch (seg.type) { case 'root': current = [data]; break; case 'property': current = applyProperty(current, seg.name); break; case 'index': current = applyIndex(current, seg.value); break; case 'slice': current = applySlice(current, seg.start, seg.end); break; case 'wildcard': current = applyWildcard(current); break; case 'filter': current = applyFilter(current, seg.condition); break; case 'filterThenIndex': current = applyFilterThenIndex(current, seg.condition, seg.index); break; case 'recursiveDescent': current = applyRecursiveDescent(current, seg.target); break; } } return current; } function applyProperty(nodes, name) { const results = []; for (const node of nodes) { if (node !== null && typeof node === 'object' && !Array.isArray(node)) { const val = node[name]; if (val !== undefined) { results.push(val); } } } return results; } function applyIndex(nodes, index) { const results = []; for (const node of nodes) { if (Array.isArray(node)) { const actual = index < 0 ? node.length + index : index; if (actual >= 0 && actual < node.length) { results.push(node[actual]); } } } return results; } function applySlice(nodes, start, end) { const results = []; for (const node of nodes) { if (Array.isArray(node)) { const s = start !== undefined ? (start < 0 ? node.length + start : start) : 0; const e = end !== undefined ? (end < 0 ? node.length + end : end) : node.length; const sliced = node.slice(s, e); for (const item of sliced) { results.push(item); } } } return results; } function applyWildcard(nodes) { const results = []; for (const node of nodes) { if (Array.isArray(node)) { for (const item of node) { results.push(item); } } else if (node !== null && typeof node === 'object') { for (const val of Object.values(node)) { results.push(val); } } } return results; } function applyFilter(nodes, condition) { const results = []; for (const node of nodes) { if (Array.isArray(node)) { for (const item of node) { if (evaluateCondition(item, condition)) { results.push(item); } } } } return results; } function applyFilterThenIndex(nodes, condition, index) { const results = []; for (const node of nodes) { if (Array.isArray(node)) { const filtered = node.filter((item) => evaluateCondition(item, condition)); const actual = index < 0 ? filtered.length + index : index; if (actual >= 0 && actual < filtered.length) { results.push(filtered[actual]); } } } return results; } function applyRecursiveDescent(nodes, target) { // Collect all descendants from every current node const allDescendants = []; for (const node of nodes) { collectDescendants(node, allDescendants); } // Apply the target segment to each descendant switch (target.type) { case 'property': { const results = []; for (const d of allDescendants) { if (d !== null && typeof d === 'object' && !Array.isArray(d)) { const val = d[target.name]; if (val !== undefined) { results.push(val); } } } return results; } case 'wildcard': { const results = []; for (const d of allDescendants) { if (Array.isArray(d)) { for (const item of d) { results.push(item); } } } return results; } case 'filter': { const results = []; for (const d of allDescendants) { if (Array.isArray(d)) { for (const item of d) { if (evaluateCondition(item, target.condition)) { results.push(item); } } } } return results; } default: throw new Error(`Unsupported recursive descent target: ${target.type}`); } } function collectDescendants(node, out) { out.push(node); if (node !== null && typeof node === 'object') { const values = Array.isArray(node) ? node : Object.values(node); for (const val of values) { collectDescendants(val, out); } } } // --------------------------------------------------------------------------- // Filter condition evaluator // --------------------------------------------------------------------------- function evaluateCondition(node, condition) { switch (condition.kind) { case 'comparison': { const left = resolveOperand(node, condition.left); const right = resolveOperand(node, condition.right); return compare(left, right, condition.op); } case 'and': return condition.conditions.every((c) => evaluateCondition(node, c)); case 'existence': { const val = resolvePathOnNode(node, condition.segments); return val !== undefined; } } } function resolveOperand(node, operand) { if (operand.kind === 'literal') { return operand.value; } return resolvePathOnNode(node, operand.segments); } function resolvePathOnNode(node, segments) { let current = node; for (const seg of segments) { if (current === null || current === undefined || typeof current !== 'object') { return undefined; } current = current[seg]; } return current; } function compare(left, right, op) { switch (op) { case '===': return left === right; case '!==': return left !== right; /* eslint-disable eqeqeq -- JSONPath == and != operators require loose equality */ case '==': return left == right; case '!=': return left != right; /* eslint-enable eqeqeq */ case '<': return left < right; case '<=': return left <= right; case '>': return left > right; case '>=': return left >= right; } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Evaluate a JSONPath expression against a data object. * * Drop-in replacement for `jsonpath.query()` covering the subset of JSONPath * used by this project. Uses no `eval()` or dynamic code execution. * * @param data - The data to query * @param expression - A JSONPath expression (must start with $) * @returns An array of matching values */ function queryJsonPath(data, expression) { const tokens = tokenize(expression); const segments = parse(tokens); return evaluate(data, segments); } //# sourceMappingURL=JsonPath.js.map