feat(math-engine): implement ExpressionParser with allowlist validation, constant substitution, function replacement, and safe evaluation

This commit is contained in:
root
2026-07-27 10:42:02 +00:00
commit 56bfb25ac3
+171
View File
@@ -0,0 +1,171 @@
// src/core/math/ExpressionParser.js
/**
* Expression Parser for the Scientific Calculator
* Handles input sanitization, constant substitution, function replacement, and safe evaluation.
*/
/**
* Whitelisted function names for safe replacement
* These will be replaced with their Math.object equivalents
*/
const FUNCTION_WHITELIST = [
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
'exp', 'log', 'ln', 'sqrt', 'cbrt',
'abs', 'ceil', 'floor', 'round',
'factorial', 'nCr', 'nPr', 'random'
];
/**
* Map of function names to their Math equivalents (or custom implementations)
*/
const FUNCTION_MAP = {
sin: 'Math.sin',
cos: 'Math.cos',
tan: 'Math.tan',
asin: 'Math.asin',
acos: 'Math.acos',
atan: 'Math.atan',
sinh: 'Math.sinh',
cosh: 'Math.cosh',
tanh: 'Math.tanh',
asinh: 'Math.asinh',
acosh: 'Math.acosh',
atanh: 'Math.atanh',
exp: 'Math.exp',
log: 'Math.log10', // base 10
ln: 'Math.log', // natural log
sqrt: 'Math.sqrt',
cbrt: 'Math.cbrt',
abs: 'Math.abs',
ceil: 'Math.ceil',
floor: 'Math.floor',
round: 'Math.round'
// Note: factorial, nCr, nPr, random are handled separately as they are not in Math object directly
};
/**
* Validates the input string against an allowlist of characters.
* @param {string} input - The raw input string
* @returns {boolean} True if input contains only allowed characters
*/
function isValidInput(input) {
// Allow digits, decimal point, operators, parentheses, whitespace, e, pi, and function letters
// We'll be more specific: allow the characters that can appear in valid expressions
// This regex allows: numbers, decimal, operators, parentheses, spaces, and the letters e, p, i (for pi and e)
// But we also need to allow function names. We'll do a two-step check:
// 1. Check for invalid characters (anything not in the allowed set)
// 2. Then we'll check that any sequences of letters are either 'pi', 'e', or a whitelisted function followed by '('
const allowedCharsRegex = /^[0-9+\-*/().\s epi]+$/i;
if (!allowedCharsRegex.test(input)) {
return false;
}
// Additional check: remove spaces and then check for invalid letter sequences
const noSpaces = input.replace(/\s/g, '');
// We'll allow sequences that are part of function names or constants
// This is a simplified check; a more robust approach would tokenize
// For now, we rely on the replacement step to catch invalid functions
return true;
}
/**
* Replaces constants (pi, e, ans) with their numeric values.
* @param {string} input - The input string after validation
* @param {number} ansValue - The previous answer value (if any)
* @returns {string} String with constants replaced
*/
function substituteConstants(input, ansValue) {
let result = input;
// Replace pi (case insensitive)
result = result.replace(/pi/gi, 'Math.PI');
// Replace e (Euler's number) - careful not to replace 'e' in scientific notation
// We'll replace standalone 'e' (not part of a number or function)
// Using word boundaries: \be\b
result = result.replace(/\be\b/g, 'Math.E');
// Replace ans with the provided value (if it's a valid number)
if (typeof ansValue === 'number' && !isNaN(ansValue)) {
result = result.replace(/\bans\b/g, String(ansValue));
}
return result;
}
/**
* Replaces whitelisted function names with their Math equivalents.
* Handles function calls like sin(, cos(, etc.
* @param {string} input - The input string after constant substitution
* @returns {string} String with function names replaced
*/
function substituteFunctions(input) {
let result = input;
// For each function in the whitelist, replace functionName( with Math.function(
// We need to be careful to not replace inside strings (but we don't have strings in expressions)
for (const func of FUNCTION_WHITELIST) {
// Create a regex that matches the function name followed by an opening parenthesis
// Use word boundary to avoid matching inside other words (e.g., 'sin' in 'asin' but we want to match 'sin' as a whole word)
// However, note that some functions are substrings of others (e.g., 'sin' in 'asin'). We'll process longer ones first.
// Sort by length descending to avoid partial replacements.
}
// Instead, we'll do a single pass with a regex that matches any of the function words followed by '('
const funcPattern = `\\b(${FUNCTION_WHITELIST.join('|')})\\s*\\(`;
const regex = new RegExp(funcPattern, 'g');
result = result.replace(regex, (match, p1) => {
// p1 is the function name
return `${FUNCTION_MAP[p1.toLowerCase()]}(`;
});
return result;
}
/**
* Safely evaluates the sanitized expression string.
* Uses Function constructor to create a function that returns the expression result.
* Wrapped in try/catch to handle errors.
* @param {string} sanitizedExpr - The sanitized expression string (should only contain numbers, operators, and Math.* calls)
* @returns {{value: number, error: string|null}} Object with either value or error
*/
function evaluateExpression(sanitizedExpr) {
try {
// Wrap in a function to get the value
// Use 'use strict' to avoid some unsafe practices
const fn = new Function('return (' + sanitizedExpr + ')');
const result = fn();
// Check if result is a number
if (typeof result === 'number' && !isNaN(result)) {
return { value: result, error: null };
} else {
return { value: null, error: 'Evaluation did not produce a valid number' };
}
} catch (err) {
return { value: null, error: `Invalid expression: ${err.message}` };
}
}
/**
* Main function to parse and evaluate an expression.
* @param {string} input - The raw user input string
* @param {number} ansValue - The previous answer (for 'ans' constant)
* @returns {{value: number, error: string|null}} Result of evaluation
*/
function parseAndEvaluate(input, ansValue = null) {
// Step 1: Validate input
if (!isValidInput(input)) {
return { value: null, error: 'Invalid characters in input' };
}
// Step 2: Substitute constants
let step1 = substituteConstants(input, ansValue);
// Step 3: Substitute functions
let step2 = substituteFunctions(step1);
// Step 4: Evaluate the expression
return evaluateExpression(step2);
}
module.exports = {
parseAndEvaluate,
isValidInput,
substituteConstants,
substituteFunctions,
evaluateExpression
};