mirror of
https://github.com/gorhill/uBlock.git
synced 2026-03-11 09:04:36 +00:00
527 lines
19 KiB
JavaScript
527 lines
19 KiB
JavaScript
/*******************************************************************************
|
|
|
|
uBlock Origin - a comprehensive, efficient content blocker
|
|
Copyright (C) 2025-present Raymond Hill
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see {http://www.gnu.org/licenses/}.
|
|
|
|
Home: https://github.com/gorhill/uBlock
|
|
|
|
*/
|
|
|
|
/**
|
|
* Implement the parsing of uBO-flavored JSON path queries.
|
|
*
|
|
* Reference to original JSON path syntax:
|
|
* https://goessner.net/articles/JsonPath/index.html
|
|
*
|
|
* uBO-flavored JSON path implementation differs as follow:
|
|
*
|
|
* - Both $ and @ are implicit. Though you can use them, you do not have to.
|
|
* Use $ only when the implicit context is not that of root. Example:
|
|
* - Official: $..book[?(@.isbn)]
|
|
* - uBO-flavored: ..book[?(.isbn)]
|
|
*
|
|
* - uBO-flavor syntax does not (yet) support:
|
|
* - Array slice operator
|
|
*
|
|
* - Regarding filter expressions, uBO-flavored JSON path supports a limited
|
|
* set of expressions since unlike the official implementation, uBO can't use
|
|
* JS eval() to evaluate arbitrary JS expressions. The operand MUST be valid
|
|
* JSON. The currently supported expressions are:
|
|
* - ==: strict equality
|
|
* - !=: strict inequality
|
|
* - <: less than
|
|
* - <=: less than or equal to
|
|
* - >: greater than
|
|
* - >=: greater than or equal to
|
|
* - ^=: stringified value starts with
|
|
* - $=: stringified value ends with
|
|
* - *=: stringified value includes
|
|
* - =/.../: regular expression matches
|
|
*
|
|
* - Examples (from "JSONPath examples" at reference link)
|
|
* - .store.book[*].author
|
|
* - ..author
|
|
* - .store.*
|
|
* - .store..price
|
|
* - ..book[2]
|
|
* - ..book[?(.isbn)]
|
|
* - ..book[?(.price<10)]
|
|
* - ..*
|
|
*
|
|
* uBO-flavored syntax supports assigning a value to a resolved JSON path by
|
|
* appending `=[value]` to the JSON path query. The assigned value MUST be
|
|
* valid JSON. Examples:
|
|
* - .store..price=0
|
|
* - .store.book[*].author="redacted"
|
|
*
|
|
* A JSONPath instance can be use to compile a JSON path query, and the result
|
|
* of the compilation can be applied to different objects. When a JSON path
|
|
* query does not assign a value, the resolved property will be removed.
|
|
*
|
|
* More capabilities can be added in the future as needed.
|
|
*
|
|
* */
|
|
|
|
export class JSONPath {
|
|
static create(query) {
|
|
const jsonp = new JSONPath();
|
|
jsonp.compile(query);
|
|
return jsonp;
|
|
}
|
|
static toJSON(obj, stringifier, ...args) {
|
|
return (stringifier || JSON.stringify)(obj, ...args)
|
|
.replace(/\//g, '\\/');
|
|
}
|
|
get value() {
|
|
return this.#compiled && this.#compiled.rval;
|
|
}
|
|
set value(v) {
|
|
if ( this.#compiled === undefined ) { return; }
|
|
this.#compiled.rval = v;
|
|
}
|
|
get valid() {
|
|
return this.#compiled !== undefined;
|
|
}
|
|
compile(query) {
|
|
this.#compiled = undefined;
|
|
const r = this.#compile(query, 0);
|
|
if ( r === undefined ) { return; }
|
|
if ( r.i !== query.length ) {
|
|
let val;
|
|
if ( query.startsWith('=', r.i) ) {
|
|
if ( /^=repl\(.+\)$/.test(query.slice(r.i)) ) {
|
|
r.modify = 'repl';
|
|
val = query.slice(r.i+6, -1);
|
|
} else {
|
|
val = query.slice(r.i+1);
|
|
}
|
|
} else if ( query.startsWith('+=', r.i) ) {
|
|
r.modify = '+';
|
|
val = query.slice(r.i+2);
|
|
}
|
|
try { r.rval = JSON.parse(val); }
|
|
catch { return; }
|
|
}
|
|
this.#compiled = r;
|
|
}
|
|
evaluate(root) {
|
|
if ( this.valid === false ) { return []; }
|
|
this.#root = root;
|
|
const paths = this.#evaluate(this.#compiled.steps, []);
|
|
this.#root = null;
|
|
return paths;
|
|
}
|
|
apply(root) {
|
|
if ( this.valid === false ) { return; }
|
|
const { rval } = this.#compiled;
|
|
this.#root = { '$': root };
|
|
const paths = this.#evaluate(this.#compiled.steps, []);
|
|
let i = paths.length
|
|
if ( i === 0 ) { this.#root = null; return; }
|
|
while ( i-- ) {
|
|
const { obj, key } = this.#resolvePath(paths[i]);
|
|
if ( rval !== undefined ) {
|
|
this.#modifyVal(obj, key);
|
|
} else if ( Array.isArray(obj) && typeof key === 'number' ) {
|
|
obj.splice(key, 1);
|
|
} else {
|
|
delete obj[key];
|
|
}
|
|
}
|
|
const result = this.#root['$'] ?? null;
|
|
this.#root = null;
|
|
return result;
|
|
}
|
|
dump() {
|
|
return JSON.stringify(this.#compiled);
|
|
}
|
|
toJSON(obj, ...args) {
|
|
return JSONPath.toJSON(obj, null, ...args)
|
|
}
|
|
get [Symbol.toStringTag]() {
|
|
return 'JSONPath';
|
|
}
|
|
#UNDEFINED = 0;
|
|
#ROOT = 1;
|
|
#CURRENT = 2;
|
|
#CHILDREN = 3;
|
|
#DESCENDANTS = 4;
|
|
#reUnquotedIdentifier = /^[A-Za-z_][\w]*|^\*/;
|
|
#reExpr = /^([!=^$*]=|[<>]=?)(.+?)\]/;
|
|
#reIndice = /^-?\d+/;
|
|
#root;
|
|
#compiled;
|
|
#compile(query, i) {
|
|
if ( query.length === 0 ) { return; }
|
|
const steps = [];
|
|
let c = query.charCodeAt(i);
|
|
if ( c === 0x24 /* $ */ ) {
|
|
steps.push({ mv: this.#ROOT });
|
|
i += 1;
|
|
} else if ( c === 0x40 /* @ */ ) {
|
|
steps.push({ mv: this.#CURRENT });
|
|
i += 1;
|
|
} else {
|
|
steps.push({ mv: i === 0 ? this.#ROOT : this.#CURRENT });
|
|
}
|
|
let mv = this.#UNDEFINED;
|
|
for (;;) {
|
|
if ( i === query.length ) { break; }
|
|
c = query.charCodeAt(i);
|
|
if ( c === 0x20 /* whitespace */ ) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
// Dot accessor syntax
|
|
if ( c === 0x2E /* . */ ) {
|
|
if ( mv !== this.#UNDEFINED ) { return; }
|
|
if ( query.startsWith('..', i) ) {
|
|
mv = this.#DESCENDANTS;
|
|
i += 2;
|
|
} else {
|
|
mv = this.#CHILDREN;
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if ( c !== 0x5B /* [ */ ) {
|
|
if ( mv === this.#UNDEFINED ) {
|
|
const step = steps.at(-1);
|
|
if ( step === undefined ) { return; }
|
|
i = this.#compileExpr(query, step, i);
|
|
break;
|
|
}
|
|
const s = this.#consumeUnquotedIdentifier(query, i);
|
|
if ( s === undefined ) { return; }
|
|
steps.push({ mv, k: s });
|
|
i += s.length;
|
|
mv = this.#UNDEFINED;
|
|
continue;
|
|
}
|
|
// Bracket accessor syntax
|
|
if ( query.startsWith('[?', i) ) {
|
|
const not = query.charCodeAt(i+2) === 0x21 /* ! */;
|
|
const j = i + 2 + (not ? 1 : 0);
|
|
const r = this.#compile(query, j);
|
|
if ( r === undefined ) { return; }
|
|
if ( query.startsWith(']', r.i) === false ) { return; }
|
|
if ( not ) { r.steps.at(-1).not = true; }
|
|
steps.push({ mv: mv || this.#CHILDREN, steps: r.steps });
|
|
i = r.i + 1;
|
|
mv = this.#UNDEFINED;
|
|
continue;
|
|
}
|
|
if ( query.startsWith('[*]', i) ) {
|
|
mv ||= this.#CHILDREN;
|
|
steps.push({ mv, k: '*' });
|
|
i += 3;
|
|
mv = this.#UNDEFINED;
|
|
continue;
|
|
}
|
|
const r = this.#consumeIdentifier(query, i+1);
|
|
if ( r === undefined ) { return; }
|
|
mv ||= this.#CHILDREN;
|
|
steps.push({ mv, k: r.s });
|
|
i = r.i + 1;
|
|
mv = this.#UNDEFINED;
|
|
}
|
|
if ( steps.length === 0 ) { return; }
|
|
if ( mv !== this.#UNDEFINED ) { return; }
|
|
return { steps, i };
|
|
}
|
|
#evaluate(steps, pathin) {
|
|
let resultset = [];
|
|
if ( Array.isArray(steps) === false ) { return resultset; }
|
|
for ( const step of steps ) {
|
|
switch ( step.mv ) {
|
|
case this.#ROOT:
|
|
resultset = [ [ '$' ] ];
|
|
break;
|
|
case this.#CURRENT:
|
|
resultset = [ pathin ];
|
|
break;
|
|
case this.#CHILDREN:
|
|
case this.#DESCENDANTS:
|
|
resultset = this.#getMatches(resultset, step);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
return resultset;
|
|
}
|
|
#getMatches(listin, step) {
|
|
const listout = [];
|
|
for ( const pathin of listin ) {
|
|
const { value: owner } = this.#resolvePath(pathin);
|
|
if ( step.k === '*' ) {
|
|
this.#getMatchesFromAll(pathin, step, owner, listout);
|
|
} else if ( step.k !== undefined ) {
|
|
this.#getMatchesFromKeys(pathin, step, owner, listout);
|
|
} else if ( step.steps ) {
|
|
this.#getMatchesFromExpr(pathin, step, owner, listout);
|
|
}
|
|
}
|
|
return listout;
|
|
}
|
|
#getMatchesFromAll(pathin, step, owner, out) {
|
|
const recursive = step.mv === this.#DESCENDANTS;
|
|
for ( const { path } of this.#getDescendants(owner, recursive) ) {
|
|
out.push([ ...pathin, ...path ]);
|
|
}
|
|
}
|
|
#getMatchesFromKeys(pathin, step, owner, out) {
|
|
const kk = Array.isArray(step.k) ? step.k : [ step.k ];
|
|
for ( const k of kk ) {
|
|
const normalized = this.#evaluateExpr(step, owner, k);
|
|
if ( normalized === undefined ) { continue; }
|
|
out.push([ ...pathin, normalized ]);
|
|
}
|
|
if ( step.mv !== this.#DESCENDANTS ) { return; }
|
|
for ( const { obj, key, path } of this.#getDescendants(owner, true) ) {
|
|
for ( const k of kk ) {
|
|
const normalized = this.#evaluateExpr(step, obj[key], k);
|
|
if ( normalized === undefined ) { continue; }
|
|
out.push([ ...pathin, ...path, normalized ]);
|
|
}
|
|
}
|
|
}
|
|
#getMatchesFromExpr(pathin, step, owner, out) {
|
|
const recursive = step.mv === this.#DESCENDANTS;
|
|
if ( Array.isArray(owner) === false ) {
|
|
const r = this.#evaluate(step.steps, pathin);
|
|
if ( r.length !== 0 ) { out.push(pathin); }
|
|
if ( recursive !== true ) { return; }
|
|
}
|
|
for ( const { obj, key, path } of this.#getDescendants(owner, recursive) ) {
|
|
if ( Array.isArray(obj[key]) ) { continue; }
|
|
const q = [ ...pathin, ...path ];
|
|
const r = this.#evaluate(step.steps, q);
|
|
if ( r.length === 0 ) { continue; }
|
|
out.push(q);
|
|
}
|
|
}
|
|
#normalizeKey(owner, key) {
|
|
if ( typeof key === 'number' ) {
|
|
if ( Array.isArray(owner) ) {
|
|
return key >= 0 ? key : owner.length + key;
|
|
}
|
|
}
|
|
return key;
|
|
}
|
|
#getDescendants(v, recursive) {
|
|
const iterator = {
|
|
next() {
|
|
const n = this.stack.length;
|
|
if ( n === 0 ) {
|
|
this.value = undefined;
|
|
this.done = true;
|
|
return this;
|
|
}
|
|
const details = this.stack[n-1];
|
|
const entry = details.keys.next();
|
|
if ( entry.done ) {
|
|
this.stack.pop();
|
|
this.path.pop();
|
|
return this.next();
|
|
}
|
|
this.path[n-1] = entry.value;
|
|
this.value = {
|
|
obj: details.obj,
|
|
key: entry.value,
|
|
path: this.path.slice(),
|
|
};
|
|
const v = this.value.obj[this.value.key];
|
|
if ( recursive ) {
|
|
if ( Array.isArray(v) ) {
|
|
this.stack.push({ obj: v, keys: v.keys() });
|
|
} else if ( typeof v === 'object' && v !== null ) {
|
|
this.stack.push({ obj: v, keys: Object.keys(v).values() });
|
|
}
|
|
}
|
|
return this;
|
|
},
|
|
path: [],
|
|
value: undefined,
|
|
done: false,
|
|
stack: [],
|
|
[Symbol.iterator]() { return this; },
|
|
};
|
|
if ( Array.isArray(v) ) {
|
|
iterator.stack.push({ obj: v, keys: v.keys() });
|
|
} else if ( typeof v === 'object' && v !== null ) {
|
|
iterator.stack.push({ obj: v, keys: Object.keys(v).values() });
|
|
}
|
|
return iterator;
|
|
}
|
|
#consumeIdentifier(query, i) {
|
|
const keys = [];
|
|
for (;;) {
|
|
const c0 = query.charCodeAt(i);
|
|
if ( c0 === 0x5D /* ] */ ) { break; }
|
|
if ( c0 === 0x2C /* , */ ) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if ( c0 === 0x27 /* ' */ ) {
|
|
const r = this.#untilChar(query, 0x27 /* ' */, i+1)
|
|
if ( r === undefined ) { return; }
|
|
keys.push(r.s);
|
|
i = r.i;
|
|
continue;
|
|
}
|
|
if ( c0 === 0x2D /* - */ || c0 >= 0x30 && c0 <= 0x39 ) {
|
|
const match = this.#reIndice.exec(query.slice(i));
|
|
if ( match === null ) { return; }
|
|
const indice = parseInt(query.slice(i), 10);
|
|
keys.push(indice);
|
|
i += match[0].length;
|
|
continue;
|
|
}
|
|
const s = this.#consumeUnquotedIdentifier(query, i);
|
|
if ( s === undefined ) { return; }
|
|
keys.push(s);
|
|
i += s.length;
|
|
}
|
|
return { s: keys.length === 1 ? keys[0] : keys, i };
|
|
}
|
|
#consumeUnquotedIdentifier(query, i) {
|
|
const match = this.#reUnquotedIdentifier.exec(query.slice(i));
|
|
if ( match === null ) { return; }
|
|
return match[0];
|
|
}
|
|
#untilChar(query, targetCharCode, i) {
|
|
const len = query.length;
|
|
const parts = [];
|
|
let beg = i, end = i;
|
|
for (;;) {
|
|
if ( end === len ) { return; }
|
|
const c = query.charCodeAt(end);
|
|
if ( c === targetCharCode ) {
|
|
parts.push(query.slice(beg, end));
|
|
end += 1;
|
|
break;
|
|
}
|
|
if ( c === 0x5C /* \ */ && (end+1) < len ) {
|
|
const d = query.charCodeAt(end+1);
|
|
if ( d === targetCharCode ) {
|
|
parts.push(query.slice(beg, end));
|
|
end += 1;
|
|
beg = end;
|
|
}
|
|
}
|
|
end += 1;
|
|
}
|
|
return { s: parts.join(''), i: end };
|
|
}
|
|
#compileExpr(query, step, i) {
|
|
if ( query.startsWith('=/', i) ) {
|
|
const r = this.#untilChar(query, 0x2F /* / */, i+2);
|
|
if ( r === undefined ) { return i; }
|
|
const match = /^[i]/.exec(query.slice(r.i));
|
|
try {
|
|
step.rval = new RegExp(r.s, match && match[0] || undefined);
|
|
} catch {
|
|
return i;
|
|
}
|
|
step.op = 're';
|
|
if ( match ) { r.i += match[0].length; }
|
|
return r.i;
|
|
}
|
|
const match = this.#reExpr.exec(query.slice(i));
|
|
if ( match === null ) { return i; }
|
|
try {
|
|
step.rval = JSON.parse(match[2]);
|
|
step.op = match[1];
|
|
} catch {
|
|
}
|
|
return i + match[1].length + match[2].length;
|
|
}
|
|
#resolvePath(path) {
|
|
if ( path.length === 0 ) { return { value: this.#root }; }
|
|
const key = path.at(-1);
|
|
let obj = this.#root
|
|
for ( let i = 0, n = path.length-1; i < n; i++ ) {
|
|
obj = obj[path[i]];
|
|
}
|
|
return { obj, key, value: obj[key] };
|
|
}
|
|
#evaluateExpr(step, owner, key) {
|
|
if ( owner === undefined || owner === null ) { return; }
|
|
if ( typeof key === 'number' ) {
|
|
if ( Array.isArray(owner) === false ) { return; }
|
|
}
|
|
const k = this.#normalizeKey(owner, key);
|
|
const hasOwn = Object.hasOwn(owner, k);
|
|
if ( step.op !== undefined && hasOwn === false ) { return; }
|
|
const target = step.not !== true;
|
|
const v = owner[k];
|
|
let outcome = false;
|
|
switch ( step.op ) {
|
|
case '==': outcome = (v === step.rval) === target; break;
|
|
case '!=': outcome = (v !== step.rval) === target; break;
|
|
case '<': outcome = (v < step.rval) === target; break;
|
|
case '<=': outcome = (v <= step.rval) === target; break;
|
|
case '>': outcome = (v > step.rval) === target; break;
|
|
case '>=': outcome = (v >= step.rval) === target; break;
|
|
case '^=': outcome = `${v}`.startsWith(step.rval) === target; break;
|
|
case '$=': outcome = `${v}`.endsWith(step.rval) === target; break;
|
|
case '*=': outcome = `${v}`.includes(step.rval) === target; break;
|
|
case 're': outcome = step.rval.test(`${v}`); break;
|
|
default: outcome = hasOwn === target; break;
|
|
}
|
|
if ( outcome ) { return k; }
|
|
}
|
|
#modifyVal(obj, key) {
|
|
let { modify, rval } = this.#compiled;
|
|
if ( typeof rval === 'string' ) {
|
|
rval = rval.replace('${now}', `${Date.now()}`);
|
|
}
|
|
switch ( modify ) {
|
|
case undefined:
|
|
obj[key] = rval;
|
|
break;
|
|
case '+': {
|
|
if ( rval instanceof Object === false ) { return; }
|
|
const lval = obj[key];
|
|
if ( lval instanceof Object === false ) { return; }
|
|
if ( Array.isArray(lval) ) { return; }
|
|
for ( const [ k, v ] of Object.entries(rval) ) {
|
|
lval[k] = v;
|
|
}
|
|
break;
|
|
}
|
|
case 'repl': {
|
|
const lval = obj[key];
|
|
if ( typeof lval !== 'string' ) { return; }
|
|
if ( this.#compiled.re === undefined ) {
|
|
this.#compiled.re = null;
|
|
try {
|
|
this.#compiled.re = rval.regex !== undefined
|
|
? new RegExp(rval.regex, rval.flags)
|
|
: new RegExp(rval.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
} catch {
|
|
}
|
|
}
|
|
if ( this.#compiled.re === null ) { return; }
|
|
obj[key] = lval.replace(this.#compiled.re, rval.replacement);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|