diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 14448f0fa8ff6c..4ec83c2a32111d 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -13,6 +13,7 @@ const options: RenovateOptions[] = [ default: ['X-*'], subType: 'string', globalOnly: true, + patternMatch: true, }, { name: 'allowedEnv', @@ -22,6 +23,7 @@ const options: RenovateOptions[] = [ default: [], subType: 'string', globalOnly: true, + patternMatch: true, }, { name: 'detectGlobalManagerConfig', @@ -949,6 +951,7 @@ const options: RenovateOptions[] = [ default: null, globalOnly: true, supportedPlatforms: ['bitbucket'], + patternMatch: true, }, { name: 'autodiscoverTopics', @@ -1230,6 +1233,7 @@ const options: RenovateOptions[] = [ mergeable: true, cli: false, env: false, + patternMatch: true, }, { name: 'excludeRepositories', diff --git a/lib/config/types.ts b/lib/config/types.ts index 2e55575c98927a..b128b39d4da6bd 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -442,6 +442,11 @@ export interface RenovateOptionBase { * This is used to add depreciation message in the docs */ deprecationMsg?: string; + + /** + * For internal use only: add it to any config option that supports regex or glob matching + */ + patternMatch?: boolean; } export interface RenovateArrayOption< diff --git a/lib/config/validation-helpers/regex-glob-matchers.spec.ts b/lib/config/validation-helpers/regex-glob-matchers.spec.ts new file mode 100644 index 00000000000000..570128326ec09c --- /dev/null +++ b/lib/config/validation-helpers/regex-glob-matchers.spec.ts @@ -0,0 +1,33 @@ +import { check } from './regex-glob-matchers'; + +describe('config/validation-helpers/regex-glob-matchers', () => { + it('should error for multiple match alls', () => { + const res = check({ + val: ['*', '**'], + currentPath: 'hostRules[0].allowedHeaders', + }); + expect(res).toHaveLength(1); + }); + + it('should error for invalid regex', () => { + const res = check({ + val: ['[', '/[/', '/.*[/'], + currentPath: 'hostRules[0].allowedHeaders', + }); + expect(res).toHaveLength(2); + }); + + it('should error for non-strings', () => { + const res = check({ + val: ['*', 2], + currentPath: 'hostRules[0].allowedHeaders', + }); + expect(res).toMatchObject([ + { + message: + 'hostRules[0].allowedHeaders: should be an array of strings. You have included object.', + topic: 'Configuration Error', + }, + ]); + }); +}); diff --git a/lib/config/validation-helpers/regex-glob-matchers.ts b/lib/config/validation-helpers/regex-glob-matchers.ts new file mode 100644 index 00000000000000..a1c25cb82f3839 --- /dev/null +++ b/lib/config/validation-helpers/regex-glob-matchers.ts @@ -0,0 +1,44 @@ +import is from '@sindresorhus/is'; +import { getRegexPredicate, isRegexMatch } from '../../util/string-match'; +import type { ValidationMessage } from '../types'; +import type { CheckMatcherArgs } from './types'; + +/** + * Only if type condition or context condition violated then errors array will be mutated to store metadata + */ +export function check({ + val: matchers, + currentPath, +}: CheckMatcherArgs): ValidationMessage[] { + const res: ValidationMessage[] = []; + + if (is.array(matchers, is.string)) { + if ( + (matchers.includes('*') || matchers.includes('**')) && + matchers.length > 1 + ) { + res.push({ + topic: 'Configuration Error', + message: `${currentPath}: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.`, + }); + } + for (const matcher of matchers) { + // Validate regex pattern + if (isRegexMatch(matcher)) { + if (!getRegexPredicate(matcher)) { + res.push({ + topic: 'Configuration Error', + message: `Failed to parse regex pattern "${matcher}"`, + }); + } + } + } + } else { + res.push({ + topic: 'Configuration Error', + message: `${currentPath}: should be an array of strings. You have included ${typeof matchers}.`, + }); + } + + return res; +} diff --git a/lib/config/validation-helpers/types.ts b/lib/config/validation-helpers/types.ts index 05f70826cfe420..68e10825820310 100644 --- a/lib/config/validation-helpers/types.ts +++ b/lib/config/validation-helpers/types.ts @@ -4,3 +4,8 @@ export interface CheckManagerArgs { resolvedRule: PackageRule; currentPath: string; } + +export interface CheckMatcherArgs { + val: unknown; + currentPath: string; +} diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index d1ca81e1a876d4..fb517bd0bb65bc 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -1225,6 +1225,34 @@ describe('config/validation', () => { expect(warnings).toHaveLength(0); expect(errors).toHaveLength(2); }); + + it('catches when * or ** is combined with others patterns in a regexOrGlob option', async () => { + const config = { + packageRules: [ + { + matchRepositories: ['groupA/**', 'groupB/**'], // valid + enabled: false, + }, + { + matchRepositories: ['*', 'repo'], // invalid + enabled: true, + }, + ], + }; + const { errors, warnings } = await configValidation.validateConfig( + 'repo', + config, + ); + expect(errors).toMatchObject([ + { + message: + 'packageRules[1].matchRepositories: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.', + topic: 'Configuration Error', + }, + ]); + expect(errors).toHaveLength(1); + expect(warnings).toHaveLength(0); + }); }); describe('validateConfig() -> globaOnly options', () => { @@ -1706,5 +1734,45 @@ describe('config/validation', () => { expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); + + it('catches when * or ** is combined with others patterns in a regexOrGlob option', async () => { + const config = { + packageRules: [ + { + matchRepositories: ['*', 'repo'], // invalid + enabled: false, + }, + ], + allowedHeaders: ['*', '**'], // invalid + autodiscoverProjects: ['**', 'project'], // invalid + allowedEnv: ['env_var'], // valid + }; + const { errors, warnings } = await configValidation.validateConfig( + 'global', + config, + ); + expect(warnings).toMatchObject([ + { + message: + 'allowedHeaders: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.', + topic: 'Configuration Error', + }, + { + message: + 'autodiscoverProjects: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.', + topic: 'Configuration Error', + }, + ]); + + expect(errors).toMatchObject([ + { + message: + 'packageRules[0].matchRepositories: Your input contains * or ** along with other patterns. Please remove them, as * or ** matches all patterns.', + topic: 'Configuration Error', + }, + ]); + expect(warnings).toHaveLength(2); + expect(errors).toHaveLength(1); + }); }); }); diff --git a/lib/config/validation.ts b/lib/config/validation.ts index e12b7fb67bf816..b629a44774e0bf 100644 --- a/lib/config/validation.ts +++ b/lib/config/validation.ts @@ -34,13 +34,16 @@ import { allowedStatusCheckStrings, } from './types'; import * as managerValidator from './validation-helpers/managers'; +import * as regexOrGlobValidator from './validation-helpers/regex-glob-matchers'; const options = getOptions(); +let optionsInitialized = false; let optionTypes: Record; let optionParents: Record; let optionGlobals: Set; let optionInherits: Set; +let optionRegexOrGlob: Set; const managerList = getManagerList(); @@ -100,27 +103,49 @@ function getDeprecationMessage(option: string): string | undefined { } function isInhertConfigOption(key: string): boolean { - if (!optionInherits) { - optionInherits = new Set(); - for (const option of options) { - if (option.inheritConfigSupport) { - optionInherits.add(option.name); - } - } - } return optionInherits.has(key); } +function isRegexOrGlobOption(key: string): boolean { + return optionRegexOrGlob.has(key); +} + function isGlobalOption(key: string): boolean { - if (!optionGlobals) { - optionGlobals = new Set(); - for (const option of options) { - if (option.globalOnly) { - optionGlobals.add(option.name); - } + return optionGlobals.has(key); +} + +function initOptions(): void { + if (optionsInitialized) { + return; + } + + optionParents = {}; + optionInherits = new Set(); + optionTypes = {}; + optionRegexOrGlob = new Set(); + optionGlobals = new Set(); + + for (const option of options) { + optionTypes[option.name] = option.type; + + if (option.parents) { + optionParents[option.name] = option.parents; + } + + if (option.inheritConfigSupport) { + optionInherits.add(option.name); + } + + if (option.patternMatch) { + optionRegexOrGlob.add(option.name); + } + + if (option.globalOnly) { + optionGlobals.add(option.name); } } - return optionGlobals.has(key); + + optionsInitialized = true; } export function getParentName(parentPath: string | undefined): string { @@ -139,20 +164,8 @@ export async function validateConfig( isPreset?: boolean, parentPath?: string, ): Promise { - if (!optionTypes) { - optionTypes = {}; - options.forEach((option) => { - optionTypes[option.name] = option.type; - }); - } - if (!optionParents) { - optionParents = {}; - options.forEach((option) => { - if (option.parents) { - optionParents[option.name] = option.parents; - } - }); - } + initOptions(); + let errors: ValidationMessage[] = []; let warnings: ValidationMessage[] = []; @@ -354,6 +367,14 @@ export async function validateConfig( errors = errors.concat(subValidation.errors); } } + if (isRegexOrGlobOption(key)) { + errors.push( + ...regexOrGlobValidator.check({ + val, + currentPath, + }), + ); + } if (key === 'extends') { for (const subval of val) { if (is.string(subval)) { @@ -951,6 +972,14 @@ async function validateGlobalConfig( } } else if (type === 'array') { if (is.array(val)) { + if (isRegexOrGlobOption(key)) { + warnings.push( + ...regexOrGlobValidator.check({ + val, + currentPath: currentPath!, + }), + ); + } if (key === 'gitNoVerify') { const allowedValues = ['commit', 'push']; for (const value of val as string[]) { diff --git a/tools/docs/config.ts b/tools/docs/config.ts index ad573f39fff1df..cac650c7101e8e 100644 --- a/tools/docs/config.ts +++ b/tools/docs/config.ts @@ -94,6 +94,7 @@ function genTable(obj: [string, string][], type: string, def: any): string { 'experimentalIssues', 'advancedUse', 'deprecationMsg', + 'patternMatch', ]; obj.forEach(([key, val]) => { const el = [key, val];