From fa08ef62eee80ce7a0306dd2d4f730807b93e190 Mon Sep 17 00:00:00 2001 From: Dan Mosedale Date: Thu, 6 Oct 2022 15:55:46 -0700 Subject: [PATCH] Add mode to throw on invalid prop access (fixes #25) --- README.md | 6 +- lib/Jexl.js | 10 +- lib/evaluator/Evaluator.js | 9 +- lib/evaluator/handlers.js | 31 ++- .../Evaluator-throw-on-missing-prop-mode.js | 210 ++++++++++++++++++ test/evaluator/Evaluator.js | 5 +- vendor/mozjexl.jsm | 50 ++++- 7 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 test/evaluator/Evaluator-throw-on-missing-prop-mode.js diff --git a/README.md b/README.md index 5a086d90..1ea2deed 100644 --- a/README.md +++ b/README.md @@ -282,10 +282,12 @@ resolve and use that value! ### API -#### mozjexl.Jexl +#### mozjexl.Jexl(throwOnMissingProp) A reference to the Jexl constructor. To maintain separate instances of Jexl with each maintaining its own set of transforms, simply re-instantiate with -`new mozjexl.Jexl()`. +`new mozjexl.Jexl()`. If `throwOnMissingProp` is set to `true`, the evaluator +will throw an exception if an expression attempts to access a property that does +not exist, otherwise it will silently continue. #### mozjexl.addBinaryOp(_{string} operator_, _{number} precedence_, _{function} fn_) Adds a binary operator to the Jexl instance. A binary operator is one that diff --git a/lib/Jexl.js b/lib/Jexl.js index 4d78eef8..ed3f9f63 100644 --- a/lib/Jexl.js +++ b/lib/Jexl.js @@ -14,10 +14,11 @@ var Evaluator = require("./evaluator/Evaluator"), * xpath-like drilldown into native Javascript objects. * @constructor */ -function Jexl() { +function Jexl(throwOnMissingProp) { this._customGrammar = null; this._lexer = null; this._transforms = {}; + this._throwOnMissingProp = throwOnMissingProp || true; } /** @@ -175,7 +176,12 @@ Jexl.prototype._eval = function(exp, context) { var self = this, grammar = this._getGrammar(), parser = new Parser(grammar), - evaluator = new Evaluator(grammar, this._transforms, context); + evaluator = new Evaluator( + grammar, + this._transforms, + context, + this._throwOnMissingProp + ); return Promise.resolve().then(function() { parser.addTokens(self._getLexer().tokenize(exp)); return evaluator.eval(parser.complete()); diff --git a/lib/evaluator/Evaluator.js b/lib/evaluator/Evaluator.js index 2eae6af7..71b635c0 100644 --- a/lib/evaluator/Evaluator.js +++ b/lib/evaluator/Evaluator.js @@ -35,11 +35,18 @@ var handlers = require("./handlers"); * to resolve the value of a relative identifier. * @constructor */ -var Evaluator = function(grammar, transforms, context, relativeContext) { +var Evaluator = function( + grammar, + transforms, + context, + relativeContext, + throwOnMissingProp +) { this._grammar = grammar; this._transforms = transforms || {}; this._context = context || {}; this._relContext = relativeContext || this._context; + this._throwOnMissingProp = throwOnMissingProp || false; }; /** diff --git a/lib/evaluator/handlers.js b/lib/evaluator/handlers.js index 5ffe32bb..9403e842 100644 --- a/lib/evaluator/handlers.js +++ b/lib/evaluator/handlers.js @@ -81,16 +81,39 @@ exports.FilterExpression = function(ast) { * @private */ exports.Identifier = function(ast) { + function contextHasProp(context, prop) { + return Object.prototype.hasOwnProperty.call(context, prop); + } + if (ast.from) { - return this.eval(ast.from).then(function(context) { + return this.eval(ast.from).then(context => { if (Array.isArray(context)) context = context[0]; + + // XXX at some point, we should probably make an undefined context throw + // too. if (context === undefined) return undefined; + + if (this._throwOnMissingProp && !contextHasProp(context, ast.value)) { + throw new Error( + `stemmed context does not have an identifier named ${ast.value}` + ); + } + return context[ast.value]; }); } else { - return ast.relative - ? this._relContext[ast.value] - : this._context[ast.value]; + const contextToCheck = ast.relative ? this._relContext : this._context; + + if ( + this._throwOnMissingProp && + !contextHasProp(contextToCheck, ast.value) + ) { + throw new Error( + `default context does not have an identifier named ${ast.value}` + ); + } + + return contextToCheck[ast.value]; } }; diff --git a/test/evaluator/Evaluator-throw-on-missing-prop-mode.js b/test/evaluator/Evaluator-throw-on-missing-prop-mode.js new file mode 100644 index 00000000..67ec1cbd --- /dev/null +++ b/test/evaluator/Evaluator-throw-on-missing-prop-mode.js @@ -0,0 +1,210 @@ +/* + * Jexl + * Copyright (c) 2015 TechnologyAdvice, + * Copyright (c) 2022 Mozilla Foundation + */ + +var chai = require("chai"), + chaiAsPromised = require("chai-as-promised"), + Lexer = require("../../lib/Lexer"), + Parser = require("../../lib/parser/Parser"), + Evaluator = require("../../lib/evaluator/Evaluator"), + grammar = require("../../lib/grammar").elements; + +chai.should(); +chai.use(chaiAsPromised); + +var lexer = new Lexer(grammar); + +function toTree(exp) { + var p = new Parser(grammar); + p.addTokens(lexer.tokenize(exp)); + return p.complete(); +} + +// If you're adding a test to this file, you probably also want to add a +// a corresponding test to Evaluator.js. + +describe("Evaluator in throwOnMissingProp mode", function() { + it("should evaluate an arithmetic expression", function() { + const e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree("(2 + 3) * 4")).should.become(20); + }); + it("should evaluate a string concat", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e + .eval(toTree('"Hello" + (4+4) + "Wo\\"rld"')) + .should.become('Hello8Wo"rld'); + }); + it("should evaluate a true comparison expression", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree("2 > 1")).should.become(true); + }); + it("should evaluate a false comparison expression", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree("2 <= 1")).should.become(false); + }); + it("should evaluate a complex expression", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e + .eval(toTree('"foo" && 6 >= 6 && 0 + 1 && true')) + .should.become(true); + }); + it("should evaluate an identifier chain", function() { + var context = { foo: { baz: { bar: "tek" } } }, + e = new Evaluator(grammar, null, context, null, true); + return e.eval(toTree("foo.baz.bar")).should.become(context.foo.baz.bar); + }); + it("should apply transforms", function() { + var context = { foo: 10 }, + half = function(val) { + return val / 2; + }, + e = new Evaluator(grammar, { half: half }, context, null, true); + return e.eval(toTree("foo|half + 3")).should.become(8); + }); + it("should filter arrays", function() { + var context = { + foo: { + bar: [{ tek: "hello" }, { tek: "baz" }, { tok: "baz" }] + } + }, + e = new Evaluator(grammar, null, context, null, true); + return e + .eval(toTree('foo.bar[.tek == "baz"]')) + .should.eventually.deep.equal([{ tek: "baz" }]); + }); + it("should assume array index 0 when traversing", function() { + var context = { + foo: { + bar: [{ tek: { hello: "world" } }, { tek: { hello: "universe" } }] + } + }, + e = new Evaluator(grammar, null, context, null, true); + return e.eval(toTree("foo.bar.tek.hello")).should.become("world"); + }); + it("should make array elements addressable by index", function() { + var context = { + foo: { + bar: [{ tek: "tok" }, { tek: "baz" }, { tek: "foz" }] + } + }, + e = new Evaluator(grammar, null, context, null, true); + return e.eval(toTree("foo.bar[1].tek")).should.become("baz"); + }); + it("should allow filters to select object properties", function() { + var context = { foo: { baz: { bar: "tek" } } }, + e = new Evaluator(grammar, null, context, null, true); + return e + .eval(toTree('foo["ba" + "z"].bar')) + .should.become(context.foo.baz.bar); + }); + it("should reject simple filters on undefined objects", function() { + var context = { foo: {} }, + e = new Evaluator(grammar, null, context, null, true); + return e.eval(toTree('foo.bar["baz"].tok')).should.be.rejected; + }); + it("should reject complex filters on undefined objects", function() { + var context = { foo: {} }, + e = new Evaluator(grammar, null, context, null, true); + return e.eval(toTree("foo.bar[.size > 1].baz")).should.be.rejected; + }); + it("should throw when transform does not exist", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree('"hello"|world')).should.be.rejected; + }); + it("should throw when top-level identifier doesn't exist", function() { + var context = { foo: { baz: { bar: "dog" } } }, + e = new Evaluator(grammar, null, context, null, true); + + return e.eval(toTree("monkey")).should.be.rejected; + }); + it("should throw when child identifier doesn't exist", function() { + var context = { foo: { baz: { bar: "cat" } } }, + e = new Evaluator(grammar, null, context, null, true); + + return e.eval(toTree("foo.baz.monkey")).should.be.rejected; + }); + it("should apply the DivFloor operator", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree("7 // 2")).should.become(3); + }); + it("should evaluate an object literal", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e + .eval(toTree('{foo: {bar: "tek"}}')) + .should.eventually.deep.equal({ foo: { bar: "tek" } }); + }); + it("should evaluate an empty object literal", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree("{}")).should.eventually.deep.equal({}); + }); + it("should evaluate a transform with multiple args", function() { + var e = new Evaluator( + grammar, + { + concat: function(val, a1, a2, a3) { + return val + ": " + a1 + a2 + a3; + } + }, + null, + null, + true + ); + return e + .eval(toTree('"foo"|concat("baz", "bar", "tek")')) + .should.become("foo: bazbartek"); + }); + it("should evaluate dot notation for object literals", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree('{foo: "bar"}.foo')).should.become("bar"); + }); + it("should allow access to literal properties", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree('"foo".length')).should.become(3); + }); + it("should evaluate array literals", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e + .eval(toTree('["foo", 1+2]')) + .should.eventually.deep.equal(["foo", 3]); + }); + it("should allow properties on empty arrays", function() { + var context = { foo: {} }, + e = new Evaluator(grammar, null, context, null, true); + return e.eval(toTree("[].baz")).should.become(undefined); + }); + it('should apply the "in" operator to strings', function() { + var e = new Evaluator(grammar, null, null, null, true); + return Promise.all([ + e.eval(toTree('"bar" in "foobartek"')).should.become(true), + e.eval(toTree('"baz" in "foobartek"')).should.become(false) + ]); + }); + it('should apply the "in" operator to arrays', function() { + var e = new Evaluator(grammar, null, null, null, true); + return Promise.all([ + e.eval(toTree('"bar" in ["foo","bar","tek"]')).should.become(true), + e.eval(toTree('"baz" in ["foo","bar","tek"]')).should.become(false) + ]); + }); + it("should evaluate a conditional expression", function() { + var e = new Evaluator(grammar, null, null, null, true); + return Promise.all([ + e.eval(toTree('"foo" ? 1 : 2')).should.become(1), + e.eval(toTree('"" ? 1 : 2')).should.become(2) + ]); + }); + it("should allow missing consequent in ternary", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree('"foo" ?: "bar"')).should.become("foo"); + }); + it("does not treat falsey properties as undefined", function() { + const e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree('"".length')).should.become(0); + }); + it("should handle an expression with arbitrary whitespace", function() { + var e = new Evaluator(grammar, null, null, null, true); + return e.eval(toTree("(\t2\n+\n3) *\n4\n\r\n")).should.become(20); + }); +}); diff --git a/test/evaluator/Evaluator.js b/test/evaluator/Evaluator.js index 49f5300a..769a50da 100644 --- a/test/evaluator/Evaluator.js +++ b/test/evaluator/Evaluator.js @@ -21,6 +21,9 @@ function toTree(exp) { return p.complete(); } +// If you're adding a test to this file, you probably also want to add a +// a corresponding test to Evaluator-throw-on-missing-prop-mode.js. + describe("Evaluator", function() { it("should evaluate an arithmetic expression", function() { const e = new Evaluator(grammar); @@ -107,7 +110,7 @@ describe("Evaluator", function() { }); it("should throw when transform does not exist", function() { var e = new Evaluator(grammar); - return e.eval(toTree('"hello"|world')).should.reject; + return e.eval(toTree('"hello"|world')).should.be.rejected; }); it("should apply the DivFloor operator", function() { var e = new Evaluator(grammar); diff --git a/vendor/mozjexl.jsm b/vendor/mozjexl.jsm index c2dc36fd..f9db945c 100644 --- a/vendor/mozjexl.jsm +++ b/vendor/mozjexl.jsm @@ -300,10 +300,11 @@ var Evaluator = __webpack_require__(2), * xpath-like drilldown into native Javascript objects. * @constructor */ -function Jexl() { +function Jexl(throwOnMissingProp) { this._customGrammar = null; this._lexer = null; this._transforms = {}; + this._throwOnMissingProp = throwOnMissingProp || true; } /** @@ -461,7 +462,12 @@ Jexl.prototype._eval = function(exp, context) { var self = this, grammar = this._getGrammar(), parser = new Parser(grammar), - evaluator = new Evaluator(grammar, this._transforms, context); + evaluator = new Evaluator( + grammar, + this._transforms, + context, + this._throwOnMissingProp + ); return Promise.resolve().then(function() { parser.addTokens(self._getLexer().tokenize(exp)); return evaluator.eval(parser.complete()); @@ -553,11 +559,18 @@ var handlers = __webpack_require__(3); * to resolve the value of a relative identifier. * @constructor */ -var Evaluator = function(grammar, transforms, context, relativeContext) { +var Evaluator = function( + grammar, + transforms, + context, + relativeContext, + throwOnMissingProp +) { this._grammar = grammar; this._transforms = transforms || {}; this._context = context || {}; this._relContext = relativeContext || this._context; + this._throwOnMissingProp = throwOnMissingProp || false; }; /** @@ -763,16 +776,39 @@ exports.FilterExpression = function(ast) { * @private */ exports.Identifier = function(ast) { + function contextHasProp(context, prop) { + return Object.prototype.hasOwnProperty.call(context, prop); + } + if (ast.from) { - return this.eval(ast.from).then(function(context) { + return this.eval(ast.from).then(context => { if (Array.isArray(context)) context = context[0]; + + // XXX at some point, we should probably make an undefined context throw + // too. if (context === undefined) return undefined; + + if (this._throwOnMissingProp && !contextHasProp(context, ast.value)) { + throw new Error( + `stemmed context does not have an identifier named ${ast.value}` + ); + } + return context[ast.value]; }); } else { - return ast.relative - ? this._relContext[ast.value] - : this._context[ast.value]; + const contextToCheck = ast.relative ? this._relContext : this._context; + + if ( + this._throwOnMissingProp && + !contextHasProp(contextToCheck, ast.value) + ) { + throw new Error( + `default context does not have an identifier named ${ast.value}` + ); + } + + return contextToCheck[ast.value]; } };