/** * @fileoverview Restrict usage of specified node imports. * @author Guy Ellis */ "use strict"; //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ const astUtils = require("./utils/ast-utils"); //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ const ignore = require("ignore"); const arrayOfStringsOrObjects = { type: "array", items: { anyOf: [ { type: "string" }, { type: "object", properties: { name: { type: "string" }, message: { type: "string", minLength: 1, }, importNames: { type: "array", items: { type: "string", }, }, allowImportNames: { type: "array", items: { type: "string", }, }, }, additionalProperties: false, required: ["name"], not: { required: ["importNames", "allowImportNames"] }, }, ], }, uniqueItems: true, }; const arrayOfStringsOrObjectPatterns = { anyOf: [ { type: "array", items: { type: "string", }, uniqueItems: true, }, { type: "array", items: { type: "object", properties: { importNames: { type: "array", items: { type: "string", }, minItems: 1, uniqueItems: true, }, allowImportNames: { type: "array", items: { type: "string", }, minItems: 1, uniqueItems: true, }, group: { type: "array", items: { type: "string", }, minItems: 1, uniqueItems: true, }, regex: { type: "string", }, importNamePattern: { type: "string", }, allowImportNamePattern: { type: "string", }, message: { type: "string", minLength: 1, }, caseSensitive: { type: "boolean", }, }, additionalProperties: false, not: { anyOf: [ { required: ["importNames", "allowImportNames"] }, { required: [ "importNamePattern", "allowImportNamePattern", ], }, { required: ["importNames", "allowImportNamePattern"] }, { required: ["importNamePattern", "allowImportNames"] }, { required: [ "allowImportNames", "allowImportNamePattern", ], }, ], }, oneOf: [{ required: ["group"] }, { required: ["regex"] }], }, uniqueItems: true, }, ], }; /** @type {import('../types').Rule.RuleModule} */ module.exports = { meta: { type: "suggestion", docs: { description: "Disallow specified modules when loaded by `import`", recommended: false, url: "https://eslint.org/docs/latest/rules/no-restricted-imports", }, messages: { path: "'{{importSource}}' import is restricted from being used.", pathWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "'{{importSource}}' import is restricted from being used. {{customMessage}}", patterns: "'{{importSource}}' import is restricted from being used by a pattern.", patternWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "'{{importSource}}' import is restricted from being used by a pattern. {{customMessage}}", patternAndImportName: "'{{importName}}' import from '{{importSource}}' is restricted from being used by a pattern.", patternAndImportNameWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "'{{importName}}' import from '{{importSource}}' is restricted from being used by a pattern. {{customMessage}}", patternAndEverything: "* import is invalid because '{{importNames}}' from '{{importSource}}' is restricted from being used by a pattern.", patternAndEverythingWithRegexImportName: "* import is invalid because import name matching '{{importNames}}' pattern from '{{importSource}}' is restricted from being used.", patternAndEverythingWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "* import is invalid because '{{importNames}}' from '{{importSource}}' is restricted from being used by a pattern. {{customMessage}}", patternAndEverythingWithRegexImportNameAndCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "* import is invalid because import name matching '{{importNames}}' pattern from '{{importSource}}' is restricted from being used. {{customMessage}}", everything: "* import is invalid because '{{importNames}}' from '{{importSource}}' is restricted.", everythingWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "* import is invalid because '{{importNames}}' from '{{importSource}}' is restricted. {{customMessage}}", importName: "'{{importName}}' import from '{{importSource}}' is restricted.", importNameWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "'{{importName}}' import from '{{importSource}}' is restricted. {{customMessage}}", allowedImportName: "'{{importName}}' import from '{{importSource}}' is restricted because only '{{allowedImportNames}}' import(s) is/are allowed.", allowedImportNameWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "'{{importName}}' import from '{{importSource}}' is restricted because only '{{allowedImportNames}}' import(s) is/are allowed. {{customMessage}}", everythingWithAllowImportNames: "* import is invalid because only '{{allowedImportNames}}' from '{{importSource}}' is/are allowed.", everythingWithAllowImportNamesAndCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "* import is invalid because only '{{allowedImportNames}}' from '{{importSource}}' is/are allowed. {{customMessage}}", allowedImportNamePattern: "'{{importName}}' import from '{{importSource}}' is restricted because only imports that match the pattern '{{allowedImportNamePattern}}' are allowed from '{{importSource}}'.", allowedImportNamePatternWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "'{{importName}}' import from '{{importSource}}' is restricted because only imports that match the pattern '{{allowedImportNamePattern}}' are allowed from '{{importSource}}'. {{customMessage}}", everythingWithAllowedImportNamePattern: "* import is invalid because only imports that match the pattern '{{allowedImportNamePattern}}' from '{{importSource}}' are allowed.", everythingWithAllowedImportNamePatternWithCustomMessage: // eslint-disable-next-line eslint-plugin/report-message-format -- Custom message might not end in a period "* import is invalid because only imports that match the pattern '{{allowedImportNamePattern}}' from '{{importSource}}' are allowed. {{customMessage}}", }, schema: { anyOf: [ arrayOfStringsOrObjects, { type: "array", items: [ { type: "object", properties: { paths: arrayOfStringsOrObjects, patterns: arrayOfStringsOrObjectPatterns, }, additionalProperties: false, }, ], additionalItems: false, }, ], }, }, create(context) { const sourceCode = context.sourceCode; const options = Array.isArray(context.options) ? context.options : []; const isPathAndPatternsObject = typeof options[0] === "object" && (Object.hasOwn(options[0], "paths") || Object.hasOwn(options[0], "patterns")); const restrictedPaths = (isPathAndPatternsObject ? options[0].paths : context.options) || []; const groupedRestrictedPaths = restrictedPaths.reduce( (memo, importSource) => { const path = typeof importSource === "string" ? importSource : importSource.name; if (!memo[path]) { memo[path] = []; } if (typeof importSource === "string") { memo[path].push({}); } else { memo[path].push({ message: importSource.message, importNames: importSource.importNames, allowImportNames: importSource.allowImportNames, }); } return memo; }, Object.create(null), ); // Handle patterns too, either as strings or groups let restrictedPatterns = (isPathAndPatternsObject ? options[0].patterns : []) || []; // standardize to array of objects if we have an array of strings if ( restrictedPatterns.length > 0 && typeof restrictedPatterns[0] === "string" ) { restrictedPatterns = [{ group: restrictedPatterns }]; } // relative paths are supported for this rule const restrictedPatternGroups = restrictedPatterns.map( ({ group, regex, message, caseSensitive, importNames, importNamePattern, allowImportNames, allowImportNamePattern, }) => ({ ...(group ? { matcher: ignore({ allowRelativePaths: true, ignorecase: !caseSensitive, }).add(group), } : {}), ...(typeof regex === "string" ? { regexMatcher: new RegExp( regex, caseSensitive ? "u" : "iu", ), } : {}), customMessage: message, importNames, importNamePattern, allowImportNames, allowImportNamePattern, }), ); // if no imports are restricted we don't need to check if ( Object.keys(restrictedPaths).length === 0 && restrictedPatternGroups.length === 0 ) { return {}; } /** * Report a restricted path. * @param {string} importSource path of the import * @param {Map} importNames Map of import names that are being imported * @param {node} node representing the restricted path reference * @returns {void} * @private */ function checkRestrictedPathAndReport(importSource, importNames, node) { if (!Object.hasOwn(groupedRestrictedPaths, importSource)) { return; } groupedRestrictedPaths[importSource].forEach( restrictedPathEntry => { const customMessage = restrictedPathEntry.message; const restrictedImportNames = restrictedPathEntry.importNames; const allowedImportNames = restrictedPathEntry.allowImportNames; if (!restrictedImportNames && !allowedImportNames) { context.report({ node, messageId: customMessage ? "pathWithCustomMessage" : "path", data: { importSource, customMessage, }, }); return; } importNames.forEach((specifiers, importName) => { if (importName === "*") { const [specifier] = specifiers; if (restrictedImportNames) { context.report({ node, messageId: customMessage ? "everythingWithCustomMessage" : "everything", loc: specifier.loc, data: { importSource, importNames: restrictedImportNames, customMessage, }, }); } else if (allowedImportNames) { context.report({ node, messageId: customMessage ? "everythingWithAllowImportNamesAndCustomMessage" : "everythingWithAllowImportNames", loc: specifier.loc, data: { importSource, allowedImportNames, customMessage, }, }); } return; } if ( restrictedImportNames && restrictedImportNames.includes(importName) ) { specifiers.forEach(specifier => { context.report({ node, messageId: customMessage ? "importNameWithCustomMessage" : "importName", loc: specifier.loc, data: { importSource, customMessage, importName, }, }); }); } if ( allowedImportNames && !allowedImportNames.includes(importName) ) { specifiers.forEach(specifier => { context.report({ node, loc: specifier.loc, messageId: customMessage ? "allowedImportNameWithCustomMessage" : "allowedImportName", data: { importSource, customMessage, importName, allowedImportNames, }, }); }); } }); }, ); } /** * Report a restricted path specifically for patterns. * @param {node} node representing the restricted path reference * @param {Object} group contains an Ignore instance for paths, the customMessage to show on failure, * and any restricted import names that have been specified in the config * @param {Map} importNames Map of import names that are being imported * @returns {void} * @private */ function reportPathForPatterns(node, group, importNames) { const importSource = node.source.value.trim(); const customMessage = group.customMessage; const restrictedImportNames = group.importNames; const restrictedImportNamePattern = group.importNamePattern ? new RegExp(group.importNamePattern, "u") : null; const allowedImportNames = group.allowImportNames; const allowedImportNamePattern = group.allowImportNamePattern ? new RegExp(group.allowImportNamePattern, "u") : null; /** * If we are not restricting to any specific import names and just the pattern itself, * report the error and move on */ if ( !restrictedImportNames && !allowedImportNames && !restrictedImportNamePattern && !allowedImportNamePattern ) { context.report({ node, messageId: customMessage ? "patternWithCustomMessage" : "patterns", data: { importSource, customMessage, }, }); return; } importNames.forEach((specifiers, importName) => { if (importName === "*") { const [specifier] = specifiers; if (restrictedImportNames) { context.report({ node, messageId: customMessage ? "patternAndEverythingWithCustomMessage" : "patternAndEverything", loc: specifier.loc, data: { importSource, importNames: restrictedImportNames, customMessage, }, }); } else if (allowedImportNames) { context.report({ node, messageId: customMessage ? "everythingWithAllowImportNamesAndCustomMessage" : "everythingWithAllowImportNames", loc: specifier.loc, data: { importSource, allowedImportNames, customMessage, }, }); } else if (allowedImportNamePattern) { context.report({ node, messageId: customMessage ? "everythingWithAllowedImportNamePatternWithCustomMessage" : "everythingWithAllowedImportNamePattern", loc: specifier.loc, data: { importSource, allowedImportNamePattern, customMessage, }, }); } else { context.report({ node, messageId: customMessage ? "patternAndEverythingWithRegexImportNameAndCustomMessage" : "patternAndEverythingWithRegexImportName", loc: specifier.loc, data: { importSource, importNames: restrictedImportNamePattern, customMessage, }, }); } return; } if ( (restrictedImportNames && restrictedImportNames.includes(importName)) || (restrictedImportNamePattern && restrictedImportNamePattern.test(importName)) ) { specifiers.forEach(specifier => { context.report({ node, messageId: customMessage ? "patternAndImportNameWithCustomMessage" : "patternAndImportName", loc: specifier.loc, data: { importSource, customMessage, importName, }, }); }); } if ( allowedImportNames && !allowedImportNames.includes(importName) ) { specifiers.forEach(specifier => { context.report({ node, messageId: customMessage ? "allowedImportNameWithCustomMessage" : "allowedImportName", loc: specifier.loc, data: { importSource, customMessage, importName, allowedImportNames, }, }); }); } else if ( allowedImportNamePattern && !allowedImportNamePattern.test(importName) ) { specifiers.forEach(specifier => { context.report({ node, messageId: customMessage ? "allowedImportNamePatternWithCustomMessage" : "allowedImportNamePattern", loc: specifier.loc, data: { importSource, customMessage, importName, allowedImportNamePattern, }, }); }); } }); } /** * Check if the given importSource is restricted by a pattern. * @param {string} importSource path of the import * @param {Object} group contains a Ignore instance for paths, and the customMessage to show if it fails * @returns {boolean} whether the variable is a restricted pattern or not * @private */ function isRestrictedPattern(importSource, group) { return group.regexMatcher ? group.regexMatcher.test(importSource) : group.matcher.ignores(importSource); } /** * Checks a node to see if any problems should be reported. * @param {ASTNode} node The node to check. * @returns {void} * @private */ function checkNode(node) { const importSource = node.source.value.trim(); const importNames = new Map(); if (node.type === "ExportAllDeclaration") { const starToken = sourceCode.getFirstToken(node, 1); importNames.set("*", [{ loc: starToken.loc }]); } else if (node.specifiers) { for (const specifier of node.specifiers) { let name; const specifierData = { loc: specifier.loc }; if (specifier.type === "ImportDefaultSpecifier") { name = "default"; } else if (specifier.type === "ImportNamespaceSpecifier") { name = "*"; } else if (specifier.imported) { name = astUtils.getModuleExportName(specifier.imported); } else if (specifier.local) { name = astUtils.getModuleExportName(specifier.local); } if (typeof name === "string") { if (importNames.has(name)) { importNames.get(name).push(specifierData); } else { importNames.set(name, [specifierData]); } } } } checkRestrictedPathAndReport(importSource, importNames, node); restrictedPatternGroups.forEach(group => { if (isRestrictedPattern(importSource, group)) { reportPathForPatterns(node, group, importNames); } }); } return { ImportDeclaration: checkNode, ExportNamedDeclaration(node) { if (node.source) { checkNode(node); } }, ExportAllDeclaration: checkNode, }; }, };