Files
Jamie Tanna 8abbd14fe6 fix(config/validation): allow only capturing currentDigest in Custom Managers (#45671)
* fix(config/validation): allow only capturing `currentDigest` in Custom Managers

As reported in #45665, it was previously possible - through a preset
prior to 44.60.1 - to specify a Custom Manager (in this case, with
Regex) that only captured the `currentDigest` for a dependency.

This causes config validation in a repository, but as it appears to be
valid - with `currentValue` defaulting to `latest` - we should make it
possible to use either `currentValue` or `currentDigest`.

* fixup! fix(config/validation): allow only capturing `currentDigest` in Custom Managers
2026-09-04 11:30:15 +00:00

216 lines
6.4 KiB
TypeScript

import {
isNonEmptyArray,
isNonEmptyString,
isNumber,
isString,
} from '@sindresorhus/is';
import jsonata from 'jsonata';
import { logger } from '../../logger/index.ts';
import type { RegexManagerTemplates } from '../../modules/manager/custom/regex/types.ts';
import type { CustomManager } from '../../modules/manager/custom/types.ts';
import { regEx } from '../../util/regex.ts';
import type { ValidationMessage } from '../types.ts';
import { ConfigValidationTopic } from './types.ts';
export function getParentName(parentPath: string | undefined): string {
return parentPath
? parentPath
.replace(regEx(/\.?encrypted$/), '')
.replace(regEx(/\[\d+\]$/), '')
.split('.')
.pop()!
: '.';
}
export function validatePlainObject(
val: Record<string, unknown>,
): true | string {
for (const [key, value] of Object.entries(val)) {
if (!isString(value)) {
return key;
}
}
return true;
}
export function validateNumber(
key: string,
val: unknown,
allowsNegative: boolean,
currentPath?: string,
subKey?: string,
): ValidationMessage[] {
const errors: ValidationMessage[] = [];
const path = `${currentPath}${subKey ? `.${subKey}` : ''}`;
if (isNumber(val)) {
if (val < 0 && !allowsNegative) {
errors.push({
topic: ConfigValidationTopic.Error,
message: `Configuration option \`${path}\` should be a positive integer. Found negative value instead.`,
});
}
} else {
errors.push({
topic: ConfigValidationTopic.Error,
message: `Configuration option \`${path}\` should be an integer. Found: ${JSON.stringify(
val,
)} (${typeof val}).`,
});
}
return errors;
}
/** An option is a false global if it has the same name as a global only option
* but is actually just the field of a non global option or field an children of the non global option
* eg. token: it's global option used as Renovate's token as well and
* also it can be the token used for a platform inside the hostRules configuration
*/
export function isFalseGlobal(
optionName: string,
parentPath?: string,
): boolean {
// v8 ignore else -- TODO: add test #40625
if (
parentPath?.includes('hostRules') &&
(optionName === 'token' ||
optionName === 'username' ||
optionName === 'password')
) {
return true;
}
return false;
}
function hasField(customManager: CustomManager, field: string): boolean {
const templateField = `${field}Template` as keyof RegexManagerTemplates;
const fieldStr =
customManager.customType === 'regex' ? `(?<${field}>` : field;
return !!(
customManager[templateField] ??
customManager.matchStrings?.some((matchString) =>
matchString.includes(fieldStr),
)
);
}
export function validateRegexManagerFields(
customManager: CustomManager,
currentPath: string,
errors: ValidationMessage[],
): void {
if (isNonEmptyArray(customManager.matchStrings)) {
for (const matchString of customManager.matchStrings) {
try {
regEx(matchString);
} catch (err) {
logger.debug(
{ err },
'customManager.matchStrings regEx validation error',
);
errors.push({
topic: ConfigValidationTopic.Error,
message: `Invalid regExp for ${currentPath}: \`${matchString}\``,
});
}
}
} else {
errors.push({
topic: ConfigValidationTopic.Error,
message:
'Each Custom Manager `matchStrings` array must have at least one item.',
});
}
const mandatoryFields = ['datasource'];
for (const field of mandatoryFields) {
if (!hasField(customManager, field)) {
errors.push({
topic: ConfigValidationTopic.Error,
message: `Regex Managers must contain ${field}Template configuration or regex group named ${field}`,
});
}
}
const versionFields = ['currentValue', 'currentDigest'];
if (!versionFields.some((field) => hasField(customManager, field))) {
const templateFields = versionFields
.map((field) => `${field}Template`)
.join(' or ');
errors.push({
topic: ConfigValidationTopic.Error,
message: `Regex Managers must contain ${versionFields.join(' or ')}, their template variants (${templateFields}) or regex groups named after configuration fields`,
});
}
const nameFields = ['depName', 'packageName'];
if (!nameFields.some((field) => hasField(customManager, field))) {
errors.push({
topic: ConfigValidationTopic.Error,
message: `Regex Managers must contain depName or packageName regex groups or templates`,
});
}
}
export function validateJSONataManagerFields(
customManager: CustomManager,
currentPath: string,
errors: ValidationMessage[],
): void {
if (!isNonEmptyString(customManager.fileFormat)) {
errors.push({
topic: ConfigValidationTopic.Error,
message: 'Each JSONata manager must contain a fileFormat field.',
});
}
if (isNonEmptyArray(customManager.matchStrings)) {
for (const matchString of customManager.matchStrings) {
try {
jsonata(matchString);
} catch (err) {
logger.debug(
{ err },
'customManager.matchStrings JSONata query validation error',
);
errors.push({
topic: ConfigValidationTopic.Error,
message: `Invalid JSONata query for ${currentPath}: \`${matchString}\``,
});
}
}
} else {
errors.push({
topic: ConfigValidationTopic.Error,
message: `Each Custom Manager must contain a non-empty matchStrings array`,
});
}
const mandatoryFields = ['datasource'];
for (const field of mandatoryFields) {
if (!hasField(customManager, field)) {
errors.push({
topic: ConfigValidationTopic.Error,
message: `JSONata Managers must contain ${field}Template configuration or ${field} in the query `,
});
}
}
const versionFields = ['currentValue', 'currentDigest'];
if (!versionFields.some((field) => hasField(customManager, field))) {
errors.push({
topic: ConfigValidationTopic.Error,
message: `JSONata Managers must contain ${versionFields.join(' or ')} in the query or their templates`,
});
}
const nameFields = ['depName', 'packageName'];
if (!nameFields.some((field) => hasField(customManager, field))) {
errors.push({
topic: ConfigValidationTopic.Error,
message: `JSONata Managers must contain depName or packageName in the query or their templates`,
});
}
}