feat(math-engine): implement ExpressionParser with allowlist validation, constant substitution, function replacement, and safe evaluation
This commit is contained in:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user