/** * @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, }; }, };