From 4d2d3e50d85c2d71f6fd9bda9a1f857da525cd79 Mon Sep 17 00:00:00 2001 From: James Fry Date: Thu, 8 Oct 2026 13:44:13 -0600 Subject: [PATCH] Add a portability audit script scripts/portability-audit.cjs (npm run portability) tells which expressions of a code base do not mean the same thing in PHP (Symfony) and in JavaScript, from the expressions alone, without any data. - Reads expressions from a JSON or text file, from stdin, or extracts them from source code (--extract): PHP attributes and annotations, YAML / XML configuration (security:, condition:, guard:, @=...), evaluate() calls. - Every leaf of an expression (variable, property chain, function call) becomes a placeholder whose type is inferred from how it is used; the expression is evaluated with the "portable" semantics on values of three kinds: typical values, edge values (0, "", "0", [], null) and values of the wrong type. - Reports the share of portable expressions per regime, the causes, which expressions would give another result with the rules of 2.x than with the default ones of 3.0, and what could not be parsed. - --fail-on sets the exit code for CI, --json writes the report, --seed makes it reproducible, --lib audits an installed package. - --php-symfony also evaluates every sample with Symfony's own PHP (scripts/portability-audit.php) and lists the answers that differ. The core is importable and takes the library as a parameter, so the tests run it against the sources; the PHP comparison is opt-in like the other PHP checks and runs in the CI job that already has PHP and Symfony. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 5 + README.md | 47 ++ package.json | 3 +- scripts/portability-audit.cjs | 949 +++++++++++++++++++++++++ scripts/portability-audit.php | 64 ++ src/__tests__/PortabilityAudit.test.js | 373 ++++++++++ 7 files changed, 1443 insertions(+), 4 deletions(-) create mode 100644 scripts/portability-audit.cjs create mode 100644 scripts/portability-audit.php create mode 100644 src/__tests__/PortabilityAudit.test.js 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'); + }); +});