JSTQL-JS-Transform/output_testing/7safe-string-coercion.js

275 lines
No EOL
11 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
function isEmptyLiteral(node) {
return node.type === 'Literal' && typeof node.value === 'string' && node.value === '';
}
function isStringLiteral(node) {
return (
// TaggedTemplateExpressions can return non-strings
node.type === 'TemplateLiteral' && node.parent.type !== 'TaggedTemplateExpression' || node.type === 'Literal' && typeof node.value === 'string'
);
}
// Symbols and Temporal.* objects will throw when using `'' + value`, but that
// pattern can be faster than `String(value)` because JS engines can optimize
// `+` better in some cases. Therefore, in perf-sensitive production codepaths
// we require using `'' + value` for string coercion. The only exception is prod
// error handling code, because it's bad to crash while assembling an error
// message or call stack! Also, error-handling code isn't usually perf-critical.
//
// Non-production codepaths (tests, devtools extension, build tools, etc.)
// should use `String(value)` because it will never crash and the (small) perf
// difference doesn't matter enough for non-prod use cases.
//
// This rule assists enforcing these guidelines:
// * `'' + value` is flagged with a message to remind developers to add a DEV
// check from shared/CheckStringCoercion.js to make sure that the user gets a
// clear error message in DEV is the coercion will throw. These checks are not
// needed if throwing is not possible, e.g. if the value is already known to
// be a string or number.
// * `String(value)` is flagged only if the `isProductionUserAppCode` option
// is set. Set this option for prod code files, and don't set it for non-prod
// files.
const ignoreKeys = ['range', 'raw', 'parent', 'loc', 'start', 'end', '_babelType', 'leadingComments', 'trailingComments'];
function astReplacer(key, value) {
return key |> ignoreKeys.includes(%) ? undefined : value;
}
/**
* Simplistic comparison between AST node. Only the following patterns are
* supported because that's almost all (all?) usage in React:
* - Identifiers, e.g. `foo`
* - Member access, e.g. `foo.bar`
* - Array access with numeric literal, e.g. `foo[0]`
*/
function isEquivalentCode(node1, node2) {
return (node1 |> JSON.stringify(%, astReplacer)) === (node2 |> JSON.stringify(%, astReplacer));
}
function isDescendant(node, maybeParentNode) {
let parent = node.parent;
while (parent) {
if (!parent) {
return false;
}
if (parent === maybeParentNode) {
return true;
}
parent = parent.parent;
}
return false;
}
function isSafeTypeofExpression(originalValueNode, node) {
if (node.type === 'BinaryExpression') {
// Example: typeof foo === 'string'
if (node.operator !== '===') {
return false;
}
const {
left,
right
} = node;
// left must be `typeof original`
if (left.type !== 'UnaryExpression' || left.operator !== 'typeof') {
return false;
}
if (!(left.argument |> isEquivalentCode(%, originalValueNode))) {
return false;
}
// right must be a literal value of a safe type
const safeTypes = ['string', 'number', 'boolean', 'undefined', 'bigint'];
if (right.type !== 'Literal' || !(right.value |> safeTypes.includes(%))) {
return false;
}
return true;
} else if (node.type === 'LogicalExpression') {
// Examples:
// * typeof foo === 'string' && typeof foo === 'number
// * typeof foo === 'string' && someOtherTest
if (node.operator === '&&') {
return originalValueNode |> isSafeTypeofExpression(%, node.left) || originalValueNode |> isSafeTypeofExpression(%, node.right);
} else if (node.operator === '||') {
return (originalValueNode |> isSafeTypeofExpression(%, node.left)) && (originalValueNode |> isSafeTypeofExpression(%, node.right));
}
}
return false;
}
/**
Returns true if the code is inside an `if` block that validates the value
excludes symbols and objects. Examples:
* if (typeof value === 'string') { }
* if (typeof value === 'string' || typeof value === 'number') { }
* if (typeof value === 'string' || someOtherTest) { }
@param - originalValueNode Top-level expression to test. Kept unchanged during
recursion.
@param - node Expression to test at current recursion level. Will be undefined
on non-recursive call.
*/
function isInSafeTypeofBlock(originalValueNode, node) {
if (!node) {
node = originalValueNode;
}
let parent = node.parent;
while (parent) {
if (!parent) {
return false;
}
// Normally, if the parent block is inside a type-safe `if` statement,
// then all child code is also type-safe. But there's a quirky case we
// need to defend against:
// if (typeof obj === 'string') { } else if (typeof obj === 'object') {'' + obj}
// if (typeof obj === 'string') { } else {'' + obj}
// In that code above, the `if` block is safe, but the `else` block is
// unsafe and should report. But the AST parent of the `else` clause is the
// `if` statement. This is the one case where the parent doesn't confer
// safety onto the child. The code below identifies that case and keeps
// moving up the tree until we get out of the `else`'s parent `if` block.
// This ensures that we don't use any of these "parents" (really siblings)
// to confer safety onto the current node.
if (parent.type === 'IfStatement' && !(originalValueNode |> isDescendant(%, parent.alternate))) {
const test = parent.test;
if (originalValueNode |> isSafeTypeofExpression(%, test)) {
return true;
}
}
parent = parent.parent;
}
}
const missingDevCheckMessage = 'Missing DEV check before this string coercion.' + ' Check should be in this format:\n' + ' if (__DEV__) {\n' + ' checkXxxxxStringCoercion(value);\n' + ' }';
const prevStatementNotDevCheckMessage = 'The statement before this coercion must be a DEV check in this format:\n' + ' if (__DEV__) {\n' + ' checkXxxxxStringCoercion(value);\n' + ' }';
/**
* Does this node have an "is coercion safe?" DEV check
* in the same block?
*/
function hasCoercionCheck(node) {
// find the containing statement
let topOfExpression = node;
while (!topOfExpression.parent.body) {
topOfExpression = topOfExpression.parent;
if (!topOfExpression) {
return 'Cannot find top of expression.';
}
}
const containingBlock = topOfExpression.parent.body;
const index = topOfExpression |> containingBlock.indexOf(%);
if (index <= 0) {
return missingDevCheckMessage;
}
const prev = containingBlock[index - 1];
// The previous statement is expected to be like this:
// if (__DEV__) {
// checkFormFieldValueStringCoercion(foo);
// }
// where `foo` must be equivalent to `node` (which is the
// mixed value being coerced to a string).
if (prev.type !== 'IfStatement' || prev.test.type !== 'Identifier' || prev.test.name !== '__DEV__') {
return prevStatementNotDevCheckMessage;
}
let maybeCheckNode = prev.consequent;
if (maybeCheckNode.type === 'BlockStatement') {
const body = maybeCheckNode.body;
if (body.length === 0) {
return prevStatementNotDevCheckMessage;
}
if (body.length !== 1) {
return 'Too many statements in DEV block before this coercion.' + ' Expected only one (the check function call). ' + prevStatementNotDevCheckMessage;
}
maybeCheckNode = body[0];
}
if (maybeCheckNode.type !== 'ExpressionStatement') {
return 'The DEV block before this coercion must only contain an expression. ' + prevStatementNotDevCheckMessage;
}
const call = maybeCheckNode.expression;
if (call.type !== 'CallExpression' || call.callee.type !== 'Identifier' || !(call.callee.name |> /^check(\w+?)StringCoercion$/.test(%)) || !call.arguments.length) {
// `maybeCheckNode` should be a call of a function named checkXXXStringCoercion
return 'Missing or invalid check function call before this coercion.' + ' Expected: call of a function like checkXXXStringCoercion. ' + prevStatementNotDevCheckMessage;
}
const same = call.arguments[0] |> isEquivalentCode(%, node);
if (!same) {
return 'Value passed to the check function before this coercion' + ' must match the value being coerced.';
}
}
function isOnlyAddingStrings(node) {
if (node.operator !== '+') {
return;
}
if ((node.left |> isStringLiteral(%)) && (node.right |> isStringLiteral(%))) {
// It's always safe to add string literals
return true;
}
if (node.left.type === 'BinaryExpression' && (node.right |> isStringLiteral(%))) {
return node.left |> isOnlyAddingStrings(%);
}
}
function checkBinaryExpression(context, node) {
if (node |> isOnlyAddingStrings(%)) {
return;
}
if (node.operator === '+' && (node.left |> isEmptyLiteral(%) || node.right |> isEmptyLiteral(%))) {
let valueToTest = node.left |> isEmptyLiteral(%) ? node.right : node.left;
if ((valueToTest.type === 'TypeCastExpression' || valueToTest.type === 'AsExpression') && valueToTest.expression) {
valueToTest = valueToTest.expression;
}
if (valueToTest.type === 'Identifier' && (valueToTest.name |> ['i', 'idx', 'lineNumber'].includes(%))) {
// Common non-object variable names are assumed to be safe
return;
}
if (valueToTest.type === 'UnaryExpression' || valueToTest.type === 'UpdateExpression') {
// Any unary expression will return a non-object, non-symbol type.
return;
}
if (valueToTest |> isInSafeTypeofBlock(%)) {
// The value is inside an if (typeof...) block that ensures it's safe
return;
}
const coercionCheckMessage = valueToTest |> hasCoercionCheck(%);
if (!coercionCheckMessage) {
// The previous statement is a correct check function call, so no report.
return;
}
({
node,
message: coercionCheckMessage + '\n' + "Using `'' + value` or `value + ''` is fast to coerce strings, but may throw." + ' For prod code, add a DEV check from shared/CheckStringCoercion immediately' + ' before this coercion.' + ' For non-prod code and prod error handling, use `String(value)` instead.'
}) |> context.report(%);
}
}
function coerceWithStringConstructor(context, node) {
const isProductionUserAppCode = context.options[0] && context.options[0].isProductionUserAppCode;
if (isProductionUserAppCode && node.callee.name === 'String') {
node |> context.report(%, "For perf-sensitive coercion, avoid `String(value)`. Instead, use `'' + value`." + ' Precede it with a DEV check from shared/CheckStringCoercion' + ' unless Symbol and Temporal.* values are impossible.' + ' For non-prod code and prod error handling, use `String(value)` and disable this rule.');
}
}
module.exports = {
meta: {
schema: [{
type: 'object',
properties: {
isProductionUserAppCode: {
type: 'boolean',
default: false
}
},
additionalProperties: false
}]
},
create(context) {
return {
BinaryExpression: node => context |> checkBinaryExpression(%, node),
CallExpression: node => context |> coerceWithStringConstructor(%, node)
};
}
};