diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index f0c09c13b1f11b591f19b3619a29c354a3010e2a..c37672f38ce3c736d3bd2d728f4002db6b4fc6ab 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', @@ -957,6 +959,7 @@ const options: RenovateOptions[] = [ default: null, globalOnly: true, supportedPlatforms: ['bitbucket'], + patternMatch: true, }, { name: 'autodiscoverTopics', @@ -1238,6 +1241,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 ff309df752a66d99ef30d5c6cf14cb12a91b283a..f900fae3b80bc6c8f87153846aa4103918a359a1 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -443,6 +443,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 0000000000000000000000000000000000000000..570128326ec09c7cfe7bab2b25cdb20fa591462a --- /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 0000000000000000000000000000000000000000..a1c25cb82f3839055a754e8c3e89e846fa33fb8a --- /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 05f70826cfe420fb41f882cea2ca468ab6159744..68e10825820310b09145efcd0da8a1f4b996d788 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 d1ca81e1a876d42f1e7e484b8b3c4700140083f1..fb517bd0bb65bc9c394949ea2bd6121eb72afabe 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 c1be3de87510e37e21c1d5d6b424d3d3c434666a..d76b33b9e134267f1e1b63b4d57053090d537b73 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<string, RenovateOptions['type']>; let optionParents: Record<string, AllowedParents[]>; let optionGlobals: Set<string>; let optionInherits: Set<string>; +let optionRegexOrGlob: Set<string>; 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<ValidationResult> { - 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)) { @@ -958,6 +979,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 ad573f39fff1dfbca43654e744ea397c5dbabb90..cac650c7101e8e3eb0face091bc8c69b8f265ddf 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];