275 lines
No EOL
11 KiB
JavaScript
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)
|
|
};
|
|
}
|
|
}; |