diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd79e1d..203b7e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,8 +95,8 @@ jobs: php: # The PHP that dumpCompiled() generates has to be accepted by Symfony's own CompiledExpressionLanguage, and behave like the file - # Symfony dumps itself. Needs the PHP of the runner and a checkout of the component (the Lexer, Parser and Nodes: no Composer). - name: PHP dump against Symfony's loader + # Symfony dumps itself; and the portability audit's comparison with Symfony's PHP has to agree. Needs the PHP of the runner and a checkout of the component (the Lexer, Parser and Nodes: no Composer). + name: PHP against Symfony's own code runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -124,7 +124,7 @@ jobs: php --version - name: Evaluate the dumped PHP with Symfony's loader - run: npm test -- CompiledExpressionLanguage + run: npm test -- 'CompiledExpressionLanguage|PortabilityAudit' env: SYMFONY_EXPRESSION_LANGUAGE_PATH: ${{ runner.temp }}/symfony-expression-language diff --git a/CHANGELOG.md b/CHANGELOG.md index 6991299..1c51b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- `scripts/portability-audit.cjs` (`npm run portability`) finds the expressions of a code base and tells which ones do not mean the same thing in PHP and in JavaScript, and + can compare the answers of this library with Symfony's own PHP for them (see the README). + ## 3.0.0 This release brings the library back in line with Symfony's ExpressionLanguage, closes a hole that let expressions read diff --git a/README.md b/README.md index e6fb25a..120a038 100644 --- a/README.md +++ b/README.md @@ -552,6 +552,53 @@ some 1,700 ns uncompiled). `'portable'` is a safety net for what the matrix covers (the operators above); it cannot know about the differences listed below. +## Auditing your expressions for portability + +If the same expressions run on a server and in a browser, you want to know which of them depend on the rules of a language. +`scripts/portability-audit.cjs` tells you, from the expressions alone: it needs no data. + +```bash +npm run build # the script audits the build in lib/ (or --lib ) +npm run portability -- --extract ./src ./config # find the expressions in your source code +npm run portability -- rules.json # ...or give a list: a JSON array of strings, or one expression per line +npm run portability -- rules.txt --fail-on ordinary # exit with 1 when an expression is not portable, to use it in CI +``` + +`--extract` reads PHP attributes and annotations (`#[IsGranted(expression: ...)]`, `@Security(...)`, `new Expression(...)`, `Assert\Expression`), +YAML and XML configuration (`security:`, `condition:`, `guard:`, `@=...`) and `evaluate()` / `compile()` calls in JavaScript. + +Every variable, property chain and function call of an expression (`object.getOwner()`, `is_granted('X')`) is replaced by a placeholder whose type +is inferred from how it is used (an operand of `<` is a number, the left side of `matches` a string...). The expression is then evaluated +with the `'portable'` semantics on values of three kinds: + +| Regime | Values | What a failure means | +|---|---|---| +| `ordinary` | typical values of the right type | the expression is not portable *whatever your data is like* (for instance two objects compared with `==`) | +| `edge` | edge values of the right type: `0`, `""`, `"0"`, `[]`, `null` | it is portable as long as such a value never shows up | +| `mixed` | values of the wrong type: numeric strings, `null`... | it is portable as long as the types are right | + +``` +Share of the audited expressions whose operators mean the same in PHP and in JavaScript, whatever the sample: + ordinary 80.7% portable (247/306) typical values of the right type + edge 72.5% portable (222/306) edge values of the right type (0, "", "0", [], null...) + mixed 8.5% portable (26/306) values of the wrong type (numeric strings, null...) + +NOT PORTABLE with ordinary values: 59. Causes (first failing sample of each): + 27 == on two objects with equal contents (distinct instances) + ... + is_granted('ROLE_USER') and object.getOwner() == user + "==" is not portable: with {"id":7} and {"id":7}, PHP gives true and JavaScript gives false. +``` + +(That is the report for 306 expressions found in open-source Symfony projects.) The report also says how many expressions would give another result with the rules of +2.x (JavaScript's) than with the default ones, which is what to look at before upgrading. + +Options: `--samples `, `--seed ` (the same seed gives the same report), `--limit `, `--json `, and `--php-symfony `, which also evaluates every sample with +Symfony's own PHP (needs `php` and a checkout of `symfony/expression-language`, see *Contributing*) and lists the answers that differ. `--help` shows them all. + +What it can and cannot tell you: it shows which operators of an expression can give another result in PHP and in JavaScript, and for which kind of value. +It does not know your data, nor what your functions return (they are placeholders); a `mixed` failure is a stress test, not a prediction. + ## Known differences from Symfony JavaScript and PHP are different languages, so a few things cannot be identical whatever the semantics: diff --git a/package.json b/package.json index c0d1d38..43c4bf6 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test": "jest", "test:coverage": "jest --coverage", "test:types": "tsc --noEmit --strict --target es2020 --moduleResolution node --esModuleInterop --skipLibCheck types/test-types.ts", - "test:build": "node scripts/smoke-build.cjs lib/index.js dist/expression-language.min.js" + "test:build": "node scripts/smoke-build.cjs lib/index.js dist/expression-language.min.js", + "portability": "node scripts/portability-audit.cjs" }, "jest": { "testPathIgnorePatterns": [ diff --git a/scripts/portability-audit.cjs b/scripts/portability-audit.cjs new file mode 100644 index 0000000..f59636f --- /dev/null +++ b/scripts/portability-audit.cjs @@ -0,0 +1,949 @@ +#!/usr/bin/env node +/* + * Portability audit: do your expressions mean the same thing in PHP (Symfony) and in JavaScript? + * + * Operators do not behave alike in the two languages (`"5" + 1`, `null == false`, `"0" ? a : b`, `owner == user` on two objects...). + * This script takes expressions (read from a file, from stdin, or extracted from the source code of a project), and tells which ones + * depend on that. + * + * It has expressions but no data, so every "leaf" of an expression (a variable, a property / method chain such as + * `object.getOwner()`, a function call such as `is_granted('X')`) is replaced by a placeholder, the type it should have is inferred + * from how it is used (an operand of `<` is a number, the left side of `matches` a string...), and the expression is evaluated with + * the "portable" semantics (see the README) on values drawn in three regimes: + * + * ordinary typical values of the right type + * edge edge values of the right type: 0, "", "0", [], null... + * mixed values of the wrong type: numeric strings for numbers, null for anything... + * + * An expression is NOT PORTABLE for a regime when at least one sample makes an operator give another result with PHP's rules than with + * JavaScript's. That is a statement about the expression, not about your data: what it takes for the data to be safe is the cause shown. + * + * Usage: + * + * node scripts/portability-audit.cjs [options] [file ...] + * + * file a JSON file (an array of strings, or of {expr: "..."}), or a text file with one expression per line + * (blank lines and lines starting with # are ignored). "-" reads the standard input. + * --extract extract the expressions from the source code under (can be repeated): PHP attributes and + * annotations (#[IsGranted(expression: ...)], @Security, Expression(...)), YAML / XML configuration + * (security:, condition:, guard:, @=...), and evaluate() / compile() calls in JavaScript + * --samples samples per expression and regime (default 300) + * --seed seed of the random values (default 20261008): the same seed gives the same report + * --limit expressions listed per section (default 15, 0 for all) + * --json write the whole report to a file + * --fail-on exit with 1 when an expression is not portable for this regime (ordinary, edge or mixed) + * --php-symfony also evaluate the samples with Symfony's own PHP and compare (needs php, and a checkout of + * symfony/expression-language; see the README, "Contributing") + * --php the php to use (default: php) + * --lib the expression-language build to audit (default: ../lib, run `npm run build` first); point it to + * node_modules/expression-language to audit the installed package + * --help + * + * Exits with 0, 1 (see --fail-on, or a disagreement with PHP) or 2 (wrong usage). + */ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const childProcess = require('child_process'); + +const REGIMES = ['ordinary', 'edge', 'mixed']; +const DEFAULT_SEED = 20261008; + +// ------------------------------------------------------------------------------------------------------------------------------ +// reading expressions +// ------------------------------------------------------------------------------------------------------------------------------ + +/** + * @param {string} text + * @param {string} name What it was read from + * @returns {{expr: string, sources: Array}[]} + */ +function parseExpressionList(text, name) { + const trimmed = text.trim(); + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + const data = JSON.parse(trimmed); + const list = Array.isArray(data) ? data : data.expressions; + if (!Array.isArray(list)) { + throw new Error(`${name}: expected a JSON array of expressions`); + } + + return list.map((item) => { + const expr = typeof item === 'string' ? item : (item.expr ?? item.expression); + if (typeof expr !== 'string') { + throw new Error(`${name}: an entry has no "expr"`); + } + + return {expr, sources: item.sources ?? [{file: name, ctx: 'list'}]}; + }); + } + + return text.split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')) + .map((expr) => ({expr, sources: [{file: name, ctx: 'list'}]})); +} + +// ------------------------------------------------------------------------------------------------------------------------------ +// extracting expressions from source code +// ------------------------------------------------------------------------------------------------------------------------------ + +const QUOTED = String.raw`(?['"])(?(?:\\.|(?!\k)[^\\])*)\k`; +const KEYS = 'security|securityPostDenormalize|securityPostValidation|expression|condition|guard'; + +const EXTRACTORS = [ + // security: '...' 'security' => '...' condition="..." + ['key', new RegExp(String.raw`['"]?\b(?:${KEYS})['"]?\s*(?:=>|:|=)\s*${QUOTED}`, 'g')], + // new Expression('...') @Security("...") #[Security('...')] Assert\Expression("...") + ['call', new RegExp(String.raw`(?:\bnew\s+)?(?:Assert\\)?\b(?:Expression|Security)\(\s*${QUOTED}`, 'g')], + // expression("...") in a service definition + ['service', new RegExp(String.raw`\bexpression\(\s*${QUOTED}\s*\)`, 'g')], + // '@=...' : the shortcut of a service definition, quoted (arguments: ['@=service("x").y()']) + ['shortcut', new RegExp(String.raw`(?['"])@=(?(?:\\.|(?!\k)[^\\])*)\k`, 'g')], + // evaluate('...') compile('...') parse('...') lint('...') in JavaScript + ['js-call', new RegExp(String.raw`\.(?:evaluate|compile|parse|lint)\(\s*${QUOTED}`, 'g')], +]; +const XML_ELEMENT = /<[\w:]*(?:guard|expression|condition)[^>]*>(?[^<]+)<\/[\w:]*(?:guard|expression|condition)>/g; +const YAML_SHORTCUT = /^\s*(?:-|[\w'".]+\s*:)\s*['"]?@=(?.+?)['"]?\s*$/; +const YAML_BARE = new RegExp(String.raw`^\s*-?\s*(?:${KEYS})\s*:\s*(?[^'"\s#][^#]*?)\s*$`); + +const EXTENSIONS = new Set(['.php', '.yaml', '.yml', '.xml', '.rst', '.md', '.twig', '.neon', '.json', '.txt', '.dist', '.js', '.cjs', '.mjs', '.ts', '.tsx', '.vue']); +const SKIPPED_DIRECTORIES = new Set(['.git', 'node_modules', 'vendor', 'translations', 'locale', 'locales', 'var', 'cache', 'dist', 'lib', 'coverage']); +const MAX_FILE_SIZE = 2 * 1024 * 1024; + +const ENTITIES = {'"': '"', ''': "'", ''': "'", ''': "'", '<': '<', '>': '>', '&': '&'}; +const decodeEntities = (text) => text.replace(/&(?:quot|#0?39|apos|lt|gt|amp);/g, (entity) => ENTITIES[entity]); + +function unescapeString(raw, quote) { + return decodeEntities(quote === "'" ? raw.replace(/\\'/g, "'").replace(/\\\\/g, '\\') : raw.replace(/\\"/g, '"').replace(/\\\\/g, '\\')).trim(); +} + +/** + * @param {string} text The content of a file + * @param {string} file Its name + * @param {Map} found Receives expression => where it was found + */ +function extractFromText(text, file, found) { + const add = (expr, ctx) => { + expr = expr.trim(); + if (expr === '' || expr.length > 400 || expr.includes('\n')) { + return; + } + if (!found.has(expr)) { + found.set(expr, []); + } + found.get(expr).push({file, ctx}); + }; + + for (const [ctx, regex] of EXTRACTORS) { + for (const match of text.matchAll(regex)) { + add(unescapeString(match.groups.e, match.groups.q), ctx); + } + } + for (const match of text.matchAll(XML_ELEMENT)) { + add(decodeEntities(match.groups.e), 'xml'); + } + for (const line of text.split('\n')) { + const shortcut = YAML_SHORTCUT.exec(line); + if (shortcut) { + add(shortcut.groups.e, 'yaml@='); + } + const bare = YAML_BARE.exec(line); + if (bare && !['true', 'false', 'null', '~'].includes(bare.groups.e.toLowerCase())) { + add(bare.groups.e, 'yaml-bare'); + } + } +} + +/** + * @param {string} root A directory (or a file) + * @returns {{expr: string, sources: Array}[]} + */ +function extractFromTree(root) { + const found = new Map(); + const visit = (target) => { + const stat = fs.statSync(target); + if (stat.isDirectory()) { + for (const name of fs.readdirSync(target)) { + if (!SKIPPED_DIRECTORIES.has(name)) { + visit(path.join(target, name)); + } + } + } else if (stat.isFile() && stat.size <= MAX_FILE_SIZE && EXTENSIONS.has(path.extname(target).toLowerCase())) { + extractFromText(fs.readFileSync(target, 'utf8'), path.relative(root, target) || path.basename(target), found); + } + }; + visit(root); + + return [...found].map(([expr, sources]) => ({expr, sources})); +} + +// ------------------------------------------------------------------------------------------------------------------------------ +// values +// ------------------------------------------------------------------------------------------------------------------------------ + +const POOLS = { + ordinary: { + bool: [true, false], + number: [1, 2, 5, 10, 100, 2.5, 42], + string: ['abc', 'Hello', 'ROLE_USER', 'john@example.com', 'draft', 'GET'], + array: [[1, 2, 3], ['a', 'b'], ['ROLE_USER', 'ROLE_ADMIN'], ['GET', 'HEAD']], + entity: [{id: 1, name: 'a'}, {id: 2, name: 'b'}, {id: 7}], + nullable: [null, 'abc', 5, {id: 1}], + }, + edge: { + bool: [true, false], + number: [0, -1, 0.5, 1000], + string: ['', '0', ' ', 'a b', '0.0'], + array: [[], [0], [[]], ['']], + entity: [null, {id: 0}], + nullable: [null, '', 0, {id: 0}], + any: [null, 0, '', '0', [], false, {}], + }, + mixed: { + bool: [1, 0, '1', '0', null], + number: ['5', '5.5', '', 'abc', null, true, '10 apples', ' 7', '0'], + string: [5, 0, null, true, false, 1.5, [], '5'], + array: ['abc', null, 'a,b', {a: 1}, 5, ''], + entity: ['abc', 5, [], null, '1'], + nullable: ['abc', 5, [], true], + any: [5, '5', null, '', 'abc', true, [], {a: 1}, 0, '0'], + }, +}; +POOLS.ordinary.any = [].concat(POOLS.ordinary.bool, POOLS.ordinary.number, POOLS.ordinary.string, POOLS.ordinary.entity); + +const clone = (value) => (value === undefined ? value : JSON.parse(JSON.stringify(value))); + +/** A small deterministic generator, so that a report can be reproduced. */ +function createRandom(seed) { + let state = seed >>> 0; + const next = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + + return state / 4294967296; + }; + + return {next, pick: (list) => list[Math.floor(next() * list.length)]}; +} + +// ------------------------------------------------------------------------------------------------------------------------------ +// the audit +// ------------------------------------------------------------------------------------------------------------------------------ + +const ARITHMETIC = new Set(['+', '-', '*', '/', '%', '**']); +const BITWISE = new Set(['&', '|', '^', '<<', '>>']); +const ORDERING = new Set(['<', '>', '<=', '>=']); +const EQUALITY = new Set(['==', '!=', '===', '!==']); +const LOGICAL = new Set(['and', 'or', 'xor', '&&', '||']); +const STRING_OPERATORS = new Set(['contains', 'starts with', 'ends with']); +const LEAF_NODES = new Set(['NameNode', 'NullCoalescedNameNode', 'GetAttrNode', 'FunctionNode']); + +// names that suggest a method or a function answers true / false +const BOOLEAN_NAME = /(^|\.)(is|has|can|are|was)[A-Z_]|^is_|^has_|is_granted|is_authenticated|is_fully|is_remember|isGranted|\.empty|\.enabled|\.active/; + +function literalFamily(node) { + if (node.name !== 'ConstantNode') { + return null; + } + const value = node.attributes.value; + if (typeof value === 'string') return 'string'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'boolean') return 'bool'; + + return value === null ? 'nullish' : null; +} + +function arrayLiteralFamily(node) { + if (node.name !== 'ArrayNode') { + return null; + } + const families = new Set(node.getKeyValuePairs().map((pair) => literalFamily(pair.value))); + + return families.size === 1 ? [...families][0] : 'string'; +} + +function safeDump(node) { + try { + return node.dump(); + } catch (e) { + return node.name; + } +} + +/** + * Replaces every leaf of a tree by a placeholder, and says what is known about each one. + */ +function rewrite(root, Placeholder) { + const leaves = []; + const operators = []; + + function visit(node, parent, key, expected, context) { + if (LEAF_NODES.has(node.name)) { + const leaf = {index: leaves.length, text: safeDump(node), expected, context, group: null}; + leaves.push(leaf); + const replacement = new Placeholder('__L' + leaf.index); + if (parent) { + parent.nodes[key] = replacement; + } + + return replacement; + } + + switch (node.name) { + case 'BinaryNode': { + const operator = node.attributes.operator; + operators.push(operator); + const {left, right} = node.nodes; + const rightLiteral = literalFamily(right); + const leftLiteral = literalFamily(left); + let leftExpected = 'any'; + let rightExpected = 'any'; + + if (ARITHMETIC.has(operator) || BITWISE.has(operator) || operator === '..') { + leftExpected = rightExpected = 'number'; + } else if (ORDERING.has(operator)) { + leftExpected = rightLiteral && rightLiteral !== 'nullish' ? rightLiteral : 'number'; + rightExpected = leftLiteral && leftLiteral !== 'nullish' ? leftLiteral : 'number'; + } else if (EQUALITY.has(operator)) { + leftExpected = rightLiteral === 'nullish' ? 'nullable' : (rightLiteral || 'same'); + rightExpected = leftLiteral === 'nullish' ? 'nullable' : (leftLiteral || 'same'); + } else if (operator === 'in' || operator === 'not in') { + const element = arrayLiteralFamily(right) || leftLiteral || 'string'; + leftExpected = element === 'nullish' ? 'string' : element; + rightExpected = 'array:' + leftExpected; + } else if (STRING_OPERATORS.has(operator) || operator === 'matches' || operator === '~') { + leftExpected = rightExpected = 'string'; + } else if (LOGICAL.has(operator)) { + leftExpected = rightExpected = 'boolish'; + } + + visit(left, node, 'left', leftExpected, `${operator} (left)`); + visit(right, node, 'right', rightExpected, `${operator} (right)`); + + // two leaves compared together hold the same kind of value + const isPlaceholder = (child) => child.name === 'NameNode' && /^__L\d+$/.test(child.attributes.name); + if (EQUALITY.has(operator) && isPlaceholder(node.nodes.left) && isPlaceholder(node.nodes.right)) { + const a = leaves[Number(node.nodes.left.attributes.name.slice(3))]; + const b = leaves[Number(node.nodes.right.attributes.name.slice(3))]; + a.group = b.group = 'g' + a.index; + } + + return node; + } + case 'UnaryNode': { + const operator = node.attributes.operator.trim(); + operators.push('unary ' + operator); + visit(node.nodes.node, node, 'node', operator === '!' || operator === 'not' ? 'boolish' : 'number', 'unary ' + operator); + + return node; + } + case 'ConditionalNode': + operators.push('?:'); + visit(node.nodes.expr1, node, 'expr1', 'boolish', 'condition'); + visit(node.nodes.expr2, node, 'expr2', expected, 'branch'); + visit(node.nodes.expr3, node, 'expr3', expected, 'branch'); + + return node; + case 'NullCoalesceNode': + operators.push('??'); + visit(node.nodes.expr1, node, 'expr1', expected, '?? (left)'); + visit(node.nodes.expr2, node, 'expr2', expected, '?? (right)'); + + return node; + case 'ArrayNode': + for (const key of Object.keys(node.nodes)) { + visit(node.nodes[key], node, key, 'any', 'array element'); + } + + return node; + } + + return node; + } + + return {tree: visit(root, null, null, 'any', 'result'), leaves, operators}; +} + +function resolveFamily(leaf) { + if (leaf.expected === 'boolish') { + // `isActive()` answers true or false; `name` or `user.owner` could be a string, a number, an object, null... + return BOOLEAN_NAME.test(leaf.text) ? 'bool' : 'any'; + } + + return leaf.expected === 'nullish' ? 'any' : leaf.expected; +} + +function sameValue(a, b) { + if (Object.is(a, b) || a === b) { + return true; + } + if (typeof a === 'number' && typeof b === 'number') { + return Number.isNaN(a) && Number.isNaN(b); + } + if (a === undefined || b === undefined) { + return (a ?? null) === (b ?? null); + } + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') { + return false; + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + return aKeys.length === bKeys.length && aKeys.every((key, index) => key === bKeys[index] && sameValue(a[key], b[key])); +} + +/** + * @param {Object} lib An expression-language build (>= 3.0) + * @param {{samples?: number, seed?: number, phpSamples?: number}} options + */ +function createAuditor(lib, {samples = 300, seed = DEFAULT_SEED, phpSamples = 60} = {}) { + const {Parser, tokenize, IGNORE_UNKNOWN_VARIABLES, IGNORE_UNKNOWN_FUNCTIONS, SEMANTICS_JS, SEMANTICS_PORTABLE, PortabilityError} = lib; + for (const [name, value] of Object.entries({Parser, tokenize, IGNORE_UNKNOWN_VARIABLES, IGNORE_UNKNOWN_FUNCTIONS, SEMANTICS_JS, SEMANTICS_PORTABLE, PortabilityError})) { + if (value === undefined) { + throw new Error(`This is not expression-language 3.0 or later: "${name}" is missing.`); + } + } + + const parse = (expression, semantics) => new Parser({}).parse(tokenize(expression), [], IGNORE_UNKNOWN_VARIABLES | IGNORE_UNKNOWN_FUNCTIONS | semantics); + // the class of the nodes that stand for a variable, without importing it + const Placeholder = parse('x', 0).constructor; + const random = createRandom(seed); + + function drawFamily(family, regime) { + if (family.startsWith('array:')) { + if (regime === 'mixed') { + return random.pick(POOLS.mixed.array); + } + if (regime === 'edge' && random.next() < 0.5) { + return []; + } + const list = []; + for (let i = Math.floor(random.next() * 4); i > 0; i--) { + list.push(drawFamily(family.slice(6), regime)); + } + + return list; + } + + return clone(random.pick(POOLS[regime][family] || POOLS[regime].any)); + } + + function sampleValues(leaves, regime) { + const values = {}; + const groupFamilies = {}; + for (const leaf of leaves) { + let family = resolveFamily(leaf); + if (family === 'same') { + const group = leaf.group || 'solo' + leaf.index; + groupFamilies[group] = groupFamilies[group] || random.pick(['entity', 'string', 'number']); + family = groupFamilies[group]; + } + values['__L' + leaf.index] = drawFamily(family, regime); + } + // leaves compared together are equal now and then (as equal but distinct instances, for objects) + const groups = {}; + for (const leaf of leaves) { + if (leaf.group) { + (groups[leaf.group] = groups[leaf.group] || []).push(leaf); + } + } + for (const members of Object.values(groups)) { + if (members.length === 2 && regime !== 'mixed' && random.next() < 0.45) { + values['__L' + members[1].index] = clone(values['__L' + members[0].index]); + } + } + + return values; + } + + const evaluate = (tree, values) => { + try { + return {value: tree.evaluate({}, clone(values))}; + } catch (error) { + return {error}; + } + }; + + /** + * @param {{expr: string, sources?: Array}[]} entries + */ + function audit(entries) { + const results = []; + const unparseable = []; + const cases = []; + + for (const entry of entries) { + const expression = entry.expr; + let symfony, js, portable; + try { + symfony = rewrite(parse(expression, 0), Placeholder); + js = rewrite(parse(expression, SEMANTICS_JS), Placeholder); + portable = rewrite(parse(expression, SEMANTICS_PORTABLE), Placeholder); + } catch (error) { + unparseable.push({expr: expression, sources: entry.sources, reason: String(error.message).split('\n')[0].slice(0, 120)}); + continue; + } + + if (symfony.leaves.length === 0 || symfony.tree.name === 'NameNode') { + results.push({expr: expression, sources: entry.sources, trivial: true, operators: symfony.operators}); + continue; + } + + let text = null; + try { + text = symfony.tree.dump(); + parse(text, 0); + } catch (error) { + text = null; + } + + const record = { + expr: expression, + sources: entry.sources, + trivial: false, + leaves: symfony.leaves.map(({text: leafText, expected, context}) => ({text: leafText, expected, context})), + operators: [...new Set(symfony.operators)], + regimes: {}, + }; + + for (const regime of REGIMES) { + const summary = {samples, notPortable: 0, resultChanges: 0, otherErrors: 0, byOperator: {}, example: null, changeExample: null}; + for (let i = 0; i < samples; i++) { + const values = sampleValues(symfony.leaves.map((leaf, index) => Object.assign({}, leaf, {group: js.leaves[index].group})), regime); + const fromSymfony = evaluate(symfony.tree, values); + const fromJs = evaluate(js.tree, values); + const fromPortable = evaluate(portable.tree, values); + + if (text && i < phpSamples) { + cases.push({expr: expression, regime, text, values, ours: 'error' in fromSymfony ? {error: `${fromSymfony.error.name}: ${fromSymfony.error.message}`} : {value: fromSymfony.value === undefined ? null : fromSymfony.value}}); + } + + if (fromPortable.error instanceof PortabilityError) { + summary.notPortable++; + summary.byOperator[fromPortable.error.operator] = (summary.byOperator[fromPortable.error.operator] || 0) + 1; + summary.example = summary.example || {values, message: fromPortable.error.message}; + } else if (fromPortable.error) { + summary.otherErrors++; + } + + // would going from JavaScript's rules (2.x) to Symfony's (the default of 3.0) change what the expression gives? + const symfonyFailed = 'error' in fromSymfony; + const jsFailed = 'error' in fromJs; + if (symfonyFailed !== jsFailed || (!symfonyFailed && !sameValue(fromSymfony.value, fromJs.value))) { + summary.resultChanges++; + summary.changeExample = summary.changeExample || { + values, + symfony: symfonyFailed ? `${fromSymfony.error.name}: ${fromSymfony.error.message}` : fromSymfony.value, + js: jsFailed ? `${fromJs.error.name}: ${fromJs.error.message}` : fromJs.value, + }; + } + } + record.regimes[regime] = summary; + } + results.push(record); + } + + return {results, unparseable, cases}; + } + + return {audit}; +} + +// ------------------------------------------------------------------------------------------------------------------------------ +// comparison with Symfony's own PHP +// ------------------------------------------------------------------------------------------------------------------------------ + +function normalizeForComparison(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? (Object.is(value, -0) ? 0 : value) : {$special: String(value)}; + } + if (value === undefined) { + return null; + } + if (Array.isArray(value)) { + return value.map(normalizeForComparison); + } + if (value !== null && typeof value === 'object') { + const keys = Object.keys(value); + + return keys.length === 0 ? [] : Object.fromEntries(keys.map((key) => [key, normalizeForComparison(value[key])])); + } + + return value; +} + +function closeNumbers(a, b) { + return typeof a === 'number' && typeof b === 'number' && Math.abs(a - b) <= 1e-12 * Math.max(1, Math.abs(a), Math.abs(b)); +} + +/** + * Compares the results of the library (default semantics) with those of Symfony's own PHP for the same expressions and values. + * + * @param {Array} cases From the audit + * @param {{php?: string, symfonyPath: string}} options + */ +function crossCheckWithPhp(cases, {php = 'php', symfonyPath}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'portability-audit-')); + try { + const casesFile = path.join(directory, 'cases.json'); + fs.writeFileSync(casesFile, JSON.stringify(cases.map(({text, values}) => ({text, values})))); + + const run = childProcess.spawnSync(php, ['-d', 'xdebug.mode=off', '-d', 'xdebug.max_nesting_level=-1', path.join(__dirname, 'portability-audit.php'), symfonyPath, casesFile], {encoding: 'utf8', maxBuffer: 1 << 30}); + if (run.error || run.status !== 0) { + throw new Error(`php failed: ${run.error ? run.error.message : (run.stderr || run.stdout).trim().slice(0, 300)}`); + } + const results = JSON.parse(run.stdout); + + const differences = []; + let same = 0; + cases.forEach((one, index) => { + const ours = one.ours; + const theirs = results[index]; + let equal; + if ('error' in ours && 'error' in theirs) { + equal = true; // both refuse: the wording is each engine's own + } else if ('error' in ours || 'error' in theirs) { + equal = false; + } else { + let a = normalizeForComparison(ours.value); + const b = normalizeForComparison(theirs.value); + if (typeof a === 'boolean' && typeof b === 'number') { + a = Number(a); // preg_match() gives 1 / 0 + } + equal = closeNumbers(a, b) || JSON.stringify(a) === JSON.stringify(b); + } + if (equal) { + same++; + } else { + differences.push({expr: one.expr, regime: one.regime, text: one.text, values: one.values, ours, php: theirs}); + } + }); + + return {total: cases.length, same, differences}; + } finally { + fs.rmSync(directory, {recursive: true, force: true}); + } +} + +// ------------------------------------------------------------------------------------------------------------------------------ +// the report +// ------------------------------------------------------------------------------------------------------------------------------ + +const MESSAGE = /^"([^"]+)" is not portable: with (.*), PHP gives (.*) and JavaScript gives (.*)\.$/s; + +function describeCause(message) { + const match = MESSAGE.exec(message); + if (!match) { + return 'other'; + } + const [, operator, operands] = match; + if (EQUALITY.has(operator)) { + return (operands.match(/\{/g) || []).length >= 2 ? `${operator} on two objects with equal contents (distinct instances)` : `${operator} on scalars or arrays`; + } + + return operator; +} + +const percent = (part, whole) => (whole === 0 ? ' 0.0' : (100 * part / whole).toFixed(1).padStart(5)); + +/** + * @param {{results: Array, unparseable: Array}} audit + * @param {{read: number, limit: number, phpCheck?: Object}} options + * @returns {string} + */ +function formatReport({results, unparseable}, {read, limit = 15, phpCheck = null}) { + const audited = results.filter((result) => !result.trivial); + const trivial = results.length - audited.length; + const lines = []; + const out = (line = '') => lines.push(line); + const cap = (list) => (limit > 0 ? list.slice(0, limit) : list); + + out('Portability audit'); + out('================='); + out(`expressions read : ${read}`); + out(` not parseable : ${unparseable.length}`); + out(` trivial (nothing to audit) : ${trivial} a bare name, or a single call: no operator is involved`); + out(` audited : ${audited.length}`); + out(); + + if (audited.length === 0) { + out('Nothing to audit.'); + } else { + out('Share of the audited expressions whose operators mean the same in PHP and in JavaScript, whatever the sample:'); + const labels = { + ordinary: 'typical values of the right type', + edge: 'edge values of the right type (0, "", "0", [], null...)', + mixed: 'values of the wrong type (numeric strings, null...)', + }; + for (const regime of REGIMES) { + const portable = audited.filter((result) => result.regimes[regime].notPortable === 0).length; + out(` ${regime.padEnd(9)} ${percent(portable, audited.length)}% portable (${portable}/${audited.length}) ${labels[regime]}`); + } + const withRightTypes = audited.filter((result) => result.regimes.ordinary.notPortable === 0 && result.regimes.edge.notPortable === 0).length; + const robust = audited.filter((result) => REGIMES.every((regime) => result.regimes[regime].notPortable === 0)).length; + out(` portable with typical AND edge values: ${withRightTypes}/${audited.length} (${percent(withRightTypes, audited.length).trim()}%)`); + out(` portable for every regime: ${robust}/${audited.length}`); + out(); + + const changes = audited.filter((result) => result.regimes.ordinary.resultChanges > 0).length; + out(`With typical values, ${changes}/${audited.length} give another result with Symfony's rules (the default of 3.0) than with JavaScript's (what 2.x did).`); + out(); + + for (const regime of ['ordinary', 'edge']) { + const bad = audited.filter((result) => result.regimes[regime].notPortable > 0); + if (bad.length === 0) { + continue; + } + const causes = new Map(); + for (const result of bad) { + const cause = describeCause(result.regimes[regime].example.message); + causes.set(cause, (causes.get(cause) || 0) + 1); + } + out(`NOT PORTABLE with ${regime} values: ${bad.length}. Causes (first failing sample of each):`); + for (const [cause, count] of [...causes].sort((a, b) => b[1] - a[1])) { + out(` ${String(count).padStart(4)} ${cause}`); + } + out(); + for (const result of cap(bad)) { + const example = result.regimes[regime].example; + out(` ${result.expr}`); + out(` ${example.message}`); + } + if (limit > 0 && bad.length > limit) { + out(` ... and ${bad.length - limit} more (--limit 0 lists all, --json writes everything)`); + } + out(); + } + + const onlyWrongTypes = audited.filter((result) => result.regimes.ordinary.notPortable === 0 && result.regimes.edge.notPortable === 0 && result.regimes.mixed.notPortable > 0); + out(`Portable as long as the types are right, not when they are not: ${onlyWrongTypes.length}`); + const operators = new Map(); + for (const result of onlyWrongTypes) { + for (const operator of Object.keys(result.regimes.mixed.byOperator)) { + operators.set(operator, (operators.get(operator) || 0) + 1); + } + } + out(` what breaks them (expressions): ${[...operators].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([operator, count]) => `${operator}: ${count}`).join(', ') || '-'}`); + for (const result of cap(onlyWrongTypes)) { + out(` ${result.expr}`); + out(` ${result.regimes.mixed.example.message}`); + } + out(); + + const usage = new Map(); + for (const result of audited) { + for (const operator of result.operators) { + usage.set(operator, (usage.get(operator) || 0) + 1); + } + } + out(`Operators used (expressions): ${[...usage].sort((a, b) => b[1] - a[1]).map(([operator, count]) => `${operator}: ${count}`).join(', ')}`); + out(); + } + + if (unparseable.length > 0) { + out(`Not parseable (${unparseable.length}): usually text that is not an expression, or syntax the language does not have`); + for (const item of cap(unparseable)) { + out(` ${item.expr.slice(0, 100)} <- ${item.reason}`); + } + out(); + } + + if (phpCheck) { + out("Comparison with Symfony's own PHP (default semantics, same expressions, same values)"); + out(` ${phpCheck.same}/${phpCheck.total} identical${phpCheck.differences.length === 0 ? '' : `, ${phpCheck.differences.length} different:`}`); + for (const difference of cap(phpCheck.differences)) { + out(` ${difference.expr} (${difference.regime})`); + out(` values: ${JSON.stringify(difference.values).slice(0, 120)}`); + out(` this library: ${JSON.stringify(difference.ours).slice(0, 100)}`); + out(` PHP : ${JSON.stringify(difference.php).slice(0, 100)}`); + } + out(); + } + + return lines.join('\n'); +} + +// ------------------------------------------------------------------------------------------------------------------------------ +// command line +// ------------------------------------------------------------------------------------------------------------------------------ + +function parseArguments(argv) { + const options = {files: [], extract: [], samples: 300, seed: DEFAULT_SEED, limit: 15, json: null, failOn: null, phpSymfony: null, php: 'php', lib: path.join(__dirname, '..', 'lib', 'index.js'), help: false}; + const takeValue = (index, name) => { + if (index + 1 >= argv.length) { + throw new Error(`${name} needs a value.`); + } + + return argv[index + 1]; + }; + const takeNumber = (index, name) => { + const value = Number(takeValue(index, name)); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} needs a whole number.`); + } + + return value; + }; + + for (let i = 0; i < argv.length; i++) { + const argument = argv[i]; + switch (argument) { + case '--help': case '-h': options.help = true; break; + case '--extract': options.extract.push(takeValue(i++, argument)); break; + case '--samples': options.samples = takeNumber(i++, argument); break; + case '--seed': options.seed = takeNumber(i++, argument); break; + case '--limit': options.limit = takeNumber(i++, argument); break; + case '--json': options.json = takeValue(i++, argument); break; + case '--fail-on': options.failOn = takeValue(i++, argument); break; + case '--php-symfony': options.phpSymfony = takeValue(i++, argument); break; + case '--php': options.php = takeValue(i++, argument); break; + case '--lib': options.lib = takeValue(i++, argument); break; + default: + if (argument.startsWith('--')) { + throw new Error(`Unknown option ${argument}.`); + } + options.files.push(argument); + } + } + + if (options.failOn !== null && !REGIMES.includes(options.failOn)) { + throw new Error(`--fail-on needs one of: ${REGIMES.join(', ')}.`); + } + if (options.samples < 1) { + throw new Error('--samples needs at least 1.'); + } + + return options; +} + +function readStandardInput() { + return fs.readFileSync(0, 'utf8'); +} + +/** + * @param {string[]} argv + * @param {{stdout?: Function, stderr?: Function, lib?: Object}} io Output functions, and a library to use instead of the one of --lib + * @returns {number} The exit code + */ +function main(argv, {stdout = (text) => process.stdout.write(text + '\n'), stderr = (text) => process.stderr.write(text + '\n'), lib = null} = {}) { + let options; + try { + options = parseArguments(argv); + } catch (error) { + stderr(`${error.message}\nTry --help.`); + + return 2; + } + + if (options.help) { + const header = fs.readFileSync(__filename, 'utf8').split('*/')[0]; + stdout(header.replace(/^#!.*\n/, '').replace(/^\/\*\n?/, '').replace(/^ \* ?/gm, '').trimEnd()); + + return 0; + } + + let entries = []; + try { + for (const file of options.files) { + entries = entries.concat(file === '-' ? parseExpressionList(readStandardInput(), 'stdin') : parseExpressionList(fs.readFileSync(file, 'utf8'), file)); + } + for (const directory of options.extract) { + entries = entries.concat(extractFromTree(directory)); + } + } catch (error) { + stderr(`Cannot read the expressions: ${error.message}`); + + return 2; + } + if (options.files.length === 0 && options.extract.length === 0) { + stderr('Give a file of expressions, "-" for the standard input, or --extract . Try --help.'); + + return 2; + } + + // the same expression found in several places is audited once + const merged = new Map(); + for (const entry of entries) { + if (!merged.has(entry.expr)) { + merged.set(entry.expr, {expr: entry.expr, sources: []}); + } + merged.get(entry.expr).sources.push(...(entry.sources || [])); + } + entries = [...merged.values()]; + + let library = lib; + if (library === null) { + try { + library = require(path.resolve(options.lib)); + } catch (error) { + stderr(`Cannot load the library from ${options.lib} (${error.message.split('\n')[0]}). Run "npm run build", or point --lib to a build.`); + + return 2; + } + } + + let auditResult; + try { + auditResult = createAuditor(library, {samples: options.samples, seed: options.seed}).audit(entries); + } catch (error) { + stderr(error.message); + + return 2; + } + + let phpCheck = null; + if (options.phpSymfony !== null) { + try { + phpCheck = crossCheckWithPhp(auditResult.cases, {php: options.php, symfonyPath: options.phpSymfony}); + } catch (error) { + stderr(error.message); + + return 2; + } + } + + stdout(formatReport(auditResult, {read: entries.length, limit: options.limit, phpCheck})); + + if (options.json !== null) { + const report = { + options: {samples: options.samples, seed: options.seed}, + results: auditResult.results, + unparseable: auditResult.unparseable, + phpCheck: phpCheck && {total: phpCheck.total, same: phpCheck.same, differences: phpCheck.differences}, + }; + fs.writeFileSync(options.json, JSON.stringify(report, null, 2)); + stdout(`Report written to ${options.json}`); + } + + let exitCode = 0; + if (options.failOn !== null) { + const bad = auditResult.results.filter((result) => !result.trivial && result.regimes[options.failOn].notPortable > 0); + if (bad.length > 0) { + stderr(`${bad.length} expression(s) are not portable with ${options.failOn} values (--fail-on ${options.failOn}).`); + exitCode = 1; + } + } + if (phpCheck !== null && phpCheck.differences.length > 0) { + stderr(`${phpCheck.differences.length} sample(s) give another result than Symfony's PHP.`); + exitCode = 1; + } + + return exitCode; +} + +module.exports = { + parseExpressionList, + extractFromText, + extractFromTree, + createAuditor, + crossCheckWithPhp, + formatReport, + describeCause, + parseArguments, + main, +}; + +if (require.main === module) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/portability-audit.php b/scripts/portability-audit.php new file mode 100644 index 0000000..cc8532d --- /dev/null +++ b/scripts/portability-audit.php @@ -0,0 +1,64 @@ + + * + * The cases are [{"text": "...", "values": {...}}]; the values are decoded as PHP arrays (a JSON object is an associative array, + * which is what an object is for the expressions evaluated here). Prints one {"value": ...} or {"error": "..."} per case. + * Warnings and deprecations do not stop an evaluation, they only make it lenient, like the PHP they come from. + */ + +if ($argc < 3 || !is_dir($argv[1]) || !is_file($argv[2])) { + fwrite(STDERR, "Usage: php {$argv[0]} \n"); + exit(2); +} + +$root = rtrim($argv[1], '/'); +spl_autoload_register(static function (string $class) use ($root): void { + $prefix = 'Symfony\\Component\\ExpressionLanguage\\'; + if (str_starts_with($class, $prefix)) { + $file = $root.'/'.str_replace('\\', '/', substr($class, \strlen($prefix))).'.php'; + if (is_file($file)) { + require $file; + } + } +}); +if (!function_exists('trigger_deprecation')) { + function trigger_deprecation(string ...$args): void + { + } +} + +use Symfony\Component\ExpressionLanguage\Lexer; +use Symfony\Component\ExpressionLanguage\Parser; + +$normalize = static function (mixed $value) use (&$normalize): mixed { + if (\is_float($value) && (is_nan($value) || is_infinite($value))) { + return ['$special' => is_nan($value) ? 'NaN' : ($value > 0 ? 'Infinity' : '-Infinity')]; + } + if (\is_array($value)) { + return array_map($normalize, $value); + } + if (\is_object($value)) { + return ['$object' => $value::class]; + } + + return $value; +}; + +$results = []; +foreach (json_decode(file_get_contents($argv[2]), true) as $case) { + set_error_handler(static fn (): bool => true); + + try { + $nodes = (new Parser([]))->parse((new Lexer())->tokenize($case['text']), array_keys($case['values'])); + $results[] = ['value' => $normalize($nodes->evaluate([], $case['values']))]; + } catch (\Throwable $e) { + $results[] = ['error' => $e::class.': '.$e->getMessage()]; + } finally { + restore_error_handler(); + } +} + +echo json_encode($results, \JSON_UNESCAPED_UNICODE | \JSON_PARTIAL_OUTPUT_ON_ERROR), "\n"; diff --git a/src/__tests__/PortabilityAudit.test.js b/src/__tests__/PortabilityAudit.test.js new file mode 100644 index 0000000..2d9b66f --- /dev/null +++ b/src/__tests__/PortabilityAudit.test.js @@ -0,0 +1,373 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import * as library from "../index"; + +const audit = require("../../scripts/portability-audit.cjs"); + +// the library under audit is the sources: no build needed +const auditor = (options = {}) => audit.createAuditor(library, {samples: 120, ...options}); +const auditOne = (expression, options) => auditor(options).audit([{expr: expression}]); +const regimesOf = (expression) => auditOne(expression).results[0].regimes; +const notPortable = (expression, regime) => regimesOf(expression)[regime].notPortable > 0; + +function temporaryDirectory() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'portability-audit-test-')); +} + +describe('reading expressions', () => { + test('a JSON array of strings or of objects', () => { + expect(audit.parseExpressionList('["a + b", "c == d"]', 'x.json').map((e) => e.expr)).toEqual(['a + b', 'c == d']); + expect(audit.parseExpressionList('[{"expr": "a + b", "sources": [{"file": "f"}]}, {"expression": "c"}]', 'x.json')).toEqual([ + {expr: 'a + b', sources: [{file: 'f'}]}, + {expr: 'c', sources: [{file: 'x.json', ctx: 'list'}]}, + ]); + expect(audit.parseExpressionList('{"expressions": ["a"]}', 'x.json')).toHaveLength(1); + }); + + test('a text file: one expression per line, comments and blank lines ignored', () => { + const list = audit.parseExpressionList('# the rules\n\na + b\n c == d \n\n# end\n', 'rules.txt'); + + expect(list.map((e) => e.expr)).toEqual(['a + b', 'c == d']); + expect(list[0].sources).toEqual([{file: 'rules.txt', ctx: 'list'}]); + }); + + test('a malformed JSON list is refused', () => { + expect(() => audit.parseExpressionList('[1, 2]', 'x.json')).toThrow('has no "expr"'); + expect(() => audit.parseExpressionList('{"nope": 1}', 'x.json')).toThrow('expected a JSON array'); + expect(() => audit.parseExpressionList('[unfinished', 'x.json')).toThrow(); + }); +}); + +describe('extracting expressions from source code', () => { + const extract = (text, file = 'f') => { + const found = new Map(); + audit.extractFromText(text, file, found); + + return [...found.keys()]; + }; + + test.each([ + ["a PHP attribute", `#[IsGranted(expression: 'is_granted("ROLE_ADMIN") and subject.owner == user')]`, ['is_granted("ROLE_ADMIN") and subject.owner == user']], + ["an API Platform operation", `new Get(security: "is_granted('ROLE_USER') and object.owner == user")`, ["is_granted('ROLE_USER') and object.owner == user"]], + ["an array option", `['security' => 'object.price > 10']`, ['object.price > 10']], + ["an annotation", `@Security("is_granted('ROLE_ADMIN') or user.age >= 18")`, ["is_granted('ROLE_ADMIN') or user.age >= 18"]], + ["new Expression", `$c = new Expression('this.getAmount() > 100');`, ['this.getAmount() > 100']], + ["a constraint", `#[Assert\\Expression("this.getCategory() in ['php', 'symfony']")]`, ["this.getCategory() in ['php', 'symfony']"]], + ["a route condition", `condition: "request.headers.get('User-Agent') matches '/firefox/i'"`, ["request.headers.get('User-Agent') matches '/firefox/i'"]], + ["a workflow guard", `guard: is_granted('ROLE_REVIEWER') and subject.isRejectable()`, ["is_granted('ROLE_REVIEWER') and subject.isRejectable()"]], + ["a service expression", `arguments: ['@=service("config").getDirectory()']`, ['service("config").getDirectory()']], + ["a service expression function", `->args([expression('parameter("kernel.debug") ? 1 : 2')])`, ['parameter("kernel.debug") ? 1 : 2']], + ["an XML attribute", ``, ["context.getMethod() in ['GET', 'HEAD']"]], + ["an XML element", `is_granted("ROLE_ADMIN")`, ['is_granted("ROLE_ADMIN")']], + ["XML entities", ``, ['request.method == "GET" and a < 3']], + ["a JavaScript call", `const ok = el.evaluate('price * quantity > 100', values);`, ['price * quantity > 100']], + ["an escaped quote", `security: 'object.name == \\'it\\''`, ["object.name == 'it'"]], + ])('%s', (name, source, expected) => { + expect(extract(source)).toEqual(expect.arrayContaining(expected)); + }); + + test('plain code and prose are not expressions', () => { + expect(extract(`$x = 1 + 2;\nfunction evaluate(a) { return a; }\n// the condition is simple\nconst s = 'security';`)).toEqual([]); + }); + + test('a multi-line string and an absurdly long one are skipped', () => { + expect(extract(`security: "a\nb"`)).toEqual([]); + expect(extract(`security: '${'a + '.repeat(200)}1'`)).toEqual([]); + }); + + test('remembers where an expression was found, and how', () => { + const found = new Map(); + audit.extractFromText(`security: 'a == b'`, 'one.yaml', found); + audit.extractFromText(`new Expression('a == b')`, 'two.php', found); + + expect(found.get('a == b')).toEqual([{file: 'one.yaml', ctx: 'key'}, {file: 'two.php', ctx: 'call'}, ]); + }); + + test('walks a tree, skipping vendor code and unknown file types, and merges what is found twice', () => { + const root = temporaryDirectory(); + try { + fs.mkdirSync(path.join(root, 'src/Controller'), {recursive: true}); + fs.mkdirSync(path.join(root, 'vendor/acme'), {recursive: true}); + fs.mkdirSync(path.join(root, 'node_modules/acme'), {recursive: true}); + fs.writeFileSync(path.join(root, 'src/Controller/A.php'), `#[IsGranted(expression: 'a == b')]`); + fs.writeFileSync(path.join(root, 'src/routes.yaml'), `condition: "a == b"\nother:\n security: "c > 1"\n`); + fs.writeFileSync(path.join(root, 'src/binary.png'), `security: 'never'`); + fs.writeFileSync(path.join(root, 'vendor/acme/V.php'), `new Expression('from vendor')`); + fs.writeFileSync(path.join(root, 'node_modules/acme/i.js'), `el.evaluate('from node_modules')`); + + const found = audit.extractFromTree(root); + + expect(found.map((e) => e.expr).sort()).toEqual(['a == b', 'c > 1']); + expect(found.find((e) => e.expr === 'a == b').sources.map((s) => s.file).sort()).toEqual([path.join('src', 'Controller', 'A.php'), path.join('src', 'routes.yaml')]); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); +}); + +describe('auditing', () => { + test('what is not an expression, or has nothing to be sensitive about, is set aside', () => { + const {results, unparseable} = auditor().audit([{expr: "is_granted('ROLE_ADMIN')"}, {expr: 'user'}, {expr: '$foo'}, {expr: '1 +'}, {expr: 'a == b'}]); + + expect(results.filter((r) => r.trivial).map((r) => r.expr)).toEqual(["is_granted('ROLE_ADMIN')", 'user']); + expect(unparseable.map((u) => u.expr)).toEqual(['$foo', '1 +']); + expect(unparseable[0].reason).toContain('Unexpected character'); + expect(results.filter((r) => !r.trivial).map((r) => r.expr)).toEqual(['a == b']); + }); + + test('unknown functions and variables are fine: they are leaves', () => { + expect(auditOne("is_granted('X') and service('y').count > 3").results[0].trivial).toBe(false); + }); + + test.each([ + // expression, [portable with ordinary values, with edge values, with wrong-typed values] + ['price > 100', [true, true, false]], + ['price * quantity >= 10', [true, true, false]], + ["status == 'draft'", [true, true, false]], + ["role in ['ROLE_A', 'ROLE_B']", [true, true, true]], + ["name starts with 'A' and name ~ '!' != ''", [true, true, false]], + ['isActive ? 1 : 2', [true, true, false]], + ['a + b', [true, true, false]], + ['count(items) > 0', [true, true, false]], + ['x ?? 5', [true, true, true]], + ])('%s', (expression, [ordinary, edge, mixed]) => { + expect([!notPortable(expression, 'ordinary'), !notPortable(expression, 'edge'), !notPortable(expression, 'mixed')]).toEqual([ordinary, edge, mixed]); + }); + + test('an ownership check is not portable, because two objects with equal contents are not the same instance in JavaScript', () => { + const regimes = regimesOf('object.getOwner() == user'); + + expect(regimes.ordinary.notPortable).toBeGreaterThan(0); + expect(regimes.ordinary.byOperator).toEqual({'==': regimes.ordinary.notPortable}); + expect(audit.describeCause(regimes.ordinary.example.message)).toBe('== on two objects with equal contents (distinct instances)'); + // ...which is what the default semantics of 3.0 change in the result, compared with 2.x + expect(regimes.ordinary.resultChanges).toBeGreaterThan(0); + expect(regimes.ordinary.changeExample.symfony).toBe(true); + expect(regimes.ordinary.changeExample.js).toBe(false); + }); + + test('truthiness of a value that is not a boolean is only a problem for edge values', () => { + expect(notPortable('user and user.active', 'ordinary')).toBe(false); + expect(notPortable('name or alias', 'edge')).toBe(true); + expect(audit.describeCause(regimesOf('name or alias').edge.example.message)).toBe('boolean test'); + }); + + test('a number held as a string is a problem when ordering or adding', () => { + const regimes = regimesOf('a + b > c'); + + expect(regimes.mixed.notPortable).toBeGreaterThan(0); + expect(Object.keys(regimes.mixed.byOperator).sort()).toEqual(expect.arrayContaining(['+'])); + }); + + test('the leaves are described', () => { + expect(auditOne("is_granted('X') and object.owner == user").results[0].leaves.map((leaf) => leaf.text)).toEqual(["is_granted(\"X\")", 'object.owner', 'user']); + }); + + test('the same seed gives the same report, another seed another sample', () => { + const run = (seed) => JSON.stringify(auditOne('object.owner == user and a > b', {seed}).results); + + expect(run(1)).toBe(run(1)); + expect(run(1)).not.toBe(run(2)); + }); + + test('it keeps what Symfony\'s PHP is asked to evaluate: the rewritten expression with its values and this library\'s answer', () => { + const {cases} = auditOne('a + b > c', {phpSamples: 5}); + + expect(cases).toHaveLength(15); + expect(new Set(cases.map((c) => c.regime))).toEqual(new Set(['ordinary', 'edge', 'mixed'])); + expect(cases[0].text).toBe('((__L0 + __L1) > __L2)'); + expect(Object.keys(cases[0].values)).toEqual(['__L0', '__L1', '__L2']); + expect('value' in cases[0].ours || 'error' in cases[0].ours).toBe(true); + }); + + test('it refuses a library that is not 3.0', () => { + expect(() => audit.createAuditor({Parser: library.Parser, tokenize: library.tokenize}, {})).toThrow('is not expression-language 3.0 or later: "IGNORE_UNKNOWN_VARIABLES" is missing.'); + }); +}); + +describe('the report', () => { + const report = (expressions, options = {}) => { + const result = auditor().audit(expressions.map((expr) => ({expr}))); + + return audit.formatReport(result, {read: expressions.length, ...options}); + }; + + test('counts, shares and causes', () => { + const text = report(['object.owner == user', 'price > 10', 'is_granted("X")', '$nope']); + + expect(text).toContain('expressions read : 4'); + expect(text).toContain('not parseable : 1'); + expect(text).toContain('trivial (nothing to audit) : 1'); + expect(text).toContain('audited : 2'); + expect(text).toMatch(/ordinary\s+50\.0%\s+portable \(1\/2\)/); + expect(text).toMatch(/mixed\s+0\.0%\s+portable \(0\/2\)/); + expect(text).toContain('NOT PORTABLE with ordinary values: 1'); + expect(text).toContain('== on two objects with equal contents (distinct instances)'); + expect(text).toContain('object.owner == user'); + expect(text).toContain('Not parseable (1)'); + expect(text).toContain('$nope'); + }); + + test('says so when there is nothing to audit', () => { + expect(report(['user', '$nope'])).toContain('Nothing to audit.'); + }); + + test('lists as many expressions as the limit allows', () => { + const many = Array.from({length: 6}, (_, i) => `object${i}.owner == user`); + + expect(report(many, {limit: 2})).toContain('... and 4 more'); + expect(report(many, {limit: 0})).not.toContain('... and'); + expect((report(many, {limit: 0}).match(/object\d\.owner == user\n/g) || []).length).toBeGreaterThanOrEqual(6); + }); + + test('a comparison with PHP is shown', () => { + const phpCheck = {total: 10, same: 9, differences: [{expr: 'a == b', regime: 'mixed', values: {__L0: 1}, ours: {value: true}, php: {value: false}}]}; + const text = report(['a == b'], {phpCheck}); + + expect(text).toContain('9/10 identical, 1 different'); + expect(text).toContain('this library: {"value":true}'); + expect(text).toContain('PHP : {"value":false}'); + }); +}); + +describe('the command line', () => { + const run = (args, extra = {}) => { + const out = []; + const err = []; + const code = audit.main(args, {stdout: (text) => out.push(text), stderr: (text) => err.push(text), lib: library, ...extra}); + + return {code, out: out.join('\n'), err: err.join('\n')}; + }; + const withFile = (content, callback, name = 'list.txt') => { + const directory = temporaryDirectory(); + try { + const file = path.join(directory, name); + fs.writeFileSync(file, content); + + return callback(file, directory); + } finally { + fs.rmSync(directory, {recursive: true, force: true}); + } + }; + + test('audits a file', () => { + withFile('price > 10\nobject.owner == user\n', (file) => { + const {code, out} = run([file, '--samples', '50']); + + expect(code).toBe(0); + expect(out).toContain('audited : 2'); + }); + }); + + test('extracts from a directory, and merges what is found twice', () => { + withFile(`security: 'a > 1'\n`, (file, directory) => { + fs.writeFileSync(path.join(directory, 'b.yaml'), `condition: "a > 1"\n`); + const {code, out} = run(['--extract', directory, file, '--samples', '20']); + + expect(code).toBe(0); + // list.txt holds the text itself (no expression), b.yaml a > 1; the same expression is audited once + expect(out).toMatch(/expressions read\s+: \d+/); + expect(out).toContain('audited : 1'); + }, 'list.txt'); + }); + + test('--fail-on turns a finding into an exit code', () => { + withFile('object.owner == user\n', (file) => { + expect(run([file, '--samples', '60', '--fail-on', 'ordinary']).code).toBe(1); + expect(run([file, '--samples', '60', '--fail-on', 'ordinary']).err).toContain('1 expression(s) are not portable with ordinary values'); + }); + withFile('price > 10\n', (file) => { + expect(run([file, '--samples', '60', '--fail-on', 'ordinary']).code).toBe(0); + expect(run([file, '--samples', '60', '--fail-on', 'edge']).code).toBe(0); + expect(run([file, '--samples', '60', '--fail-on', 'mixed']).code).toBe(1); + }); + withFile('price > 10\n', (file) => expect(run([file, '--samples', '60']).code).toBe(0)); + }); + + test('--json writes the whole report', () => { + withFile('object.owner == user\n$nope\n', (file, directory) => { + const json = path.join(directory, 'report.json'); + const {out} = run([file, '--samples', '30', '--json', json]); + const written = JSON.parse(fs.readFileSync(json, 'utf8')); + + expect(out).toContain(`Report written to ${json}`); + expect(written.options).toEqual({samples: 30, seed: 20261008}); + expect(written.results).toHaveLength(1); + expect(written.results[0].regimes.ordinary.example.message).toContain('is not portable'); + expect(written.unparseable[0].expr).toBe('$nope'); + }); + }); + + test('--help shows the usage', () => { + const {code, out} = run(['--help']); + + expect(code).toBe(0); + expect(out).toContain('Portability audit: do your expressions mean the same thing in PHP (Symfony) and in JavaScript?'); + expect(out).toContain('--extract '); + expect(out).not.toContain("'use strict'"); + }); + + test.each([ + [[], 'Give a file of expressions'], + [['--nope'], 'Unknown option --nope'], + [['x.txt', '--samples'], '--samples needs a value'], + [['x.txt', '--samples', 'many'], '--samples needs a whole number'], + [['x.txt', '--samples', '0'], '--samples needs at least 1'], + [['x.txt', '--fail-on', 'always'], '--fail-on needs one of: ordinary, edge, mixed'], + [['/does/not/exist.txt'], 'Cannot read the expressions'], + ])('wrong usage %j is exit code 2', (args, message) => { + const {code, err} = run(args); + + expect(code).toBe(2); + expect(err).toContain(message); + }); + + test('a library that cannot be loaded is exit code 2', () => { + withFile('a > 1\n', (file) => { + const {code, err} = run([file, '--lib', '/does/not/exist'], {lib: null}); + + expect(code).toBe(2); + expect(err).toContain('Cannot load the library from /does/not/exist'); + }); + }); + + test('argument parsing', () => { + expect(audit.parseArguments(['a.txt', '--extract', 'src', '--extract', 'app', '--limit', '0', '--seed', '7', '--php', '/usr/bin/php8'])).toMatchObject({ + files: ['a.txt'], extract: ['src', 'app'], limit: 0, seed: 7, php: '/usr/bin/php8', samples: 300, + }); + }); +}); + +// The comparison with Symfony's own PHP needs php and a checkout of the component: opt in with +// +// SYMFONY_EXPRESSION_LANGUAGE_PATH=/path/to/checkout npm test -- PortabilityAudit +const symfonyPath = process.env.SYMFONY_EXPRESSION_LANGUAGE_PATH; +const phpTest = symfonyPath ? test : test.skip; + +describe('the comparison with Symfony\'s PHP', () => { + phpTest('the default semantics give the answers of Symfony for typical, edge and wrongly-typed values', () => { + const expressions = [ + 'object.getOwner() == user', "is_granted('ROLE_USER') and price > 10", 'a + b > c', "name starts with 'A' or name ~ '!' == ''", + 'x ?? 5', 'list[0] in [1, 2, 3]', "status == 'draft' ? total * 2 : total / 2", 'not (a and b) or c', 'a ? b : c', 'a <=> b', + ]; + const {cases} = auditor({phpSamples: 40}).audit(expressions.map((expr) => ({expr}))); + const check = audit.crossCheckWithPhp(cases, {php: process.env.PHP_BIN || 'php', symfonyPath}); + + expect(check.total).toBeGreaterThan(500); + expect(check.differences).toEqual([]); + expect(check.same).toBe(check.total); + }); + + phpTest('a difference is reported', () => { + const cases = [{expr: 'a', regime: 'ordinary', text: '(1 + 1)', values: {}, ours: {value: 3}}]; + const check = audit.crossCheckWithPhp(cases, {php: process.env.PHP_BIN || 'php', symfonyPath}); + + expect(check.differences).toHaveLength(1); + expect(check.differences[0].php).toEqual({value: 2}); + }); + + phpTest('a php that cannot run is an error', () => { + expect(() => audit.crossCheckWithPhp([{text: '1', values: {}, ours: {value: 1}}], {php: '/does/not/exist', symfonyPath})).toThrow('php failed'); + }); +});