import { CBFileSystem } from '../node/file-system'; import { IparamType, IparamDefn, IparamDefns, VALID_IPARAM_TYPES } from '../types/common'; import { IPARAM_DEFN_MAX_LENGTHS, IPARAMS_INSTALLATION_PARAMETERS_ALLOWED_KEYS, IPARAMS_INSTALLATION_PARAMETERS_LABEL, IPARAMS_JSON_FILENAME, IPARAMS_MAX_COUNT, IPARAMS_ROOT_ALLOWED_KEYS, IPARAMS_SECTIONS_LABEL, OPTIONS_LIMITS } from './iparam-constants'; import { IparamInputsValidator } from './iparams-inputs-validator'; import * as validatorUtils from './validator-utils'; /** * Validator for iparamDefns * Validates the sectioned iparams.json schema */ export class IparamDefnsValidator { private fileSystem: CBFileSystem; private inputsValidator: IparamInputsValidator; constructor(fileSystem: CBFileSystem) { this.fileSystem = fileSystem; this.inputsValidator = new IparamInputsValidator(fileSystem); } /** * Validates and loads the iparamDefns from a file * @param {string} path - Path to the iparams.json file * @returns {IparamDefns} The validated iparamDefns * @throws {Error} If the iparamDefns is invalid or cannot be read */ validateAndGetIparamDefnsFile(path: string): IparamDefns { validatorUtils.ensureFileExists(this.fileSystem, path); try { const iparamDefns = JSON.parse(this.fileSystem.readFileSync(path, 'utf8')); try { this.validate(iparamDefns); return iparamDefns; } catch (validationError) { const err = new Error((validationError as Error).message); (err as Error & { rawContent?: IparamDefns }).rawContent = iparamDefns; throw err; } } catch (error) { if ((error as Error & { rawContent?: unknown }).rawContent !== undefined) { throw error; } if (error instanceof SyntaxError) { throw new Error(`Failed to parse ${IPARAMS_JSON_FILENAME}: ${error.message}`); } throw new Error(`Failed to read ${IPARAMS_JSON_FILENAME}: ${(error as Error).message}`); } } /** * Validates an iparamDefns object * @param {IparamDefns} iparamDefns - The iparamDefns object to validate * @throws {Error} If the iparamDefns is invalid */ validate(iparamDefns: IparamDefns): void { if (!iparamDefns || typeof iparamDefns !== 'object' || Array.isArray(iparamDefns)) { throw new Error(`${IPARAMS_JSON_FILENAME} must be an object`); } this.rejectUnknownKeys(iparamDefns, IPARAMS_ROOT_ALLOWED_KEYS, IPARAMS_JSON_FILENAME); if (!Object.prototype.hasOwnProperty.call(iparamDefns, IPARAMS_INSTALLATION_PARAMETERS_LABEL)) { return; } const installationParameters = iparamDefns.installation_parameters; if (!installationParameters || typeof installationParameters !== 'object' || Array.isArray(installationParameters)) { throw new Error( `${IPARAMS_JSON_FILENAME} must include ${IPARAMS_INSTALLATION_PARAMETERS_LABEL} as an object containing a ${IPARAMS_SECTIONS_LABEL} array` ); } this.rejectUnknownKeys( installationParameters, IPARAMS_INSTALLATION_PARAMETERS_ALLOWED_KEYS, IPARAMS_INSTALLATION_PARAMETERS_LABEL ); if (!Object.prototype.hasOwnProperty.call(installationParameters, IPARAMS_SECTIONS_LABEL)) { throw new Error( `${IPARAMS_INSTALLATION_PARAMETERS_LABEL} must include ${IPARAMS_SECTIONS_LABEL} in ${IPARAMS_JSON_FILENAME}` ); } const sections = installationParameters.sections; if (!Array.isArray(sections)) { throw new Error( `${IPARAMS_INSTALLATION_PARAMETERS_LABEL}.${IPARAMS_SECTIONS_LABEL} must be an array of section objects` ); } if (sections.length === 0) { throw new Error( `${IPARAMS_INSTALLATION_PARAMETERS_LABEL}.${IPARAMS_SECTIONS_LABEL} must contain at least one section` ); } for (let i = 0; i < sections.length; i++) { this.validateSection(sections[i], i); } const totalParamCount = sections.reduce((count, section) => count + section.parameters.length, 0); if (totalParamCount > IPARAMS_MAX_COUNT) { throw new Error( `${IPARAMS_JSON_FILENAME} cannot define more than ${IPARAMS_MAX_COUNT} parameters (found ${totalParamCount})` ); } const sectionNames = sections.map((section) => section.name.trim()); const seenSectionNames = new Set(); const duplicateSections: string[] = []; for (const name of sectionNames) { if (seenSectionNames.has(name)) duplicateSections.push(name); else seenSectionNames.add(name); } if (duplicateSections.length > 0) { throw new Error(`Duplicate section names found: ${duplicateSections.join(', ')}`); } for (const section of sections) { const paramNames = section.parameters.map((param) => param.name.trim()); const seenParamNames = new Set(); const duplicateParams: string[] = []; for (const name of paramNames) { if (seenParamNames.has(name)) duplicateParams.push(name); else seenParamNames.add(name); } if (duplicateParams.length > 0) { throw new Error( `Duplicate parameter names found in section "${section.name}": ${duplicateParams.join(', ')}` ); } } } /** * Validates a single section definition * @param {any} section - The section definition to validate * @param {number} index - The index of the section in the array (for error messages) * @throws {Error} If the section is invalid */ private validateSection(section: any, index: number): void { if (!section || typeof section !== 'object') { throw new Error(`Section at index ${index} must be an object`); } const sectionName = this.validateSectionName(section, index); this.requireNonEmptyStringField(section, sectionName, 'display_name', IPARAM_DEFN_MAX_LENGTHS.DISPLAY_NAME, 'Section'); this.requireNonEmptyStringField(section, sectionName, 'description', IPARAM_DEFN_MAX_LENGTHS.DESCRIPTION, 'Section'); const parameters = section['parameters']; if (!Array.isArray(parameters)) { throw new Error(`Section "${sectionName}": "parameters" must be an array`); } if (parameters.length === 0) { throw new Error(`Section "${sectionName}": "parameters" must contain at least one parameter`); } for (let i = 0; i < parameters.length; i++) { this.validateParameter(parameters[i], sectionName, i); } } /** * Validates a single parameter definition * @param {any} param - The parameter definition to validate * @param {string} sectionName - Parent section name for error messages * @param {number} index - The index of the parameter in the section (for error messages) * @throws {Error} If the parameter is invalid */ private validateParameter(param: any, sectionName: string, index: number): void { if (!param || typeof param !== 'object') { throw new Error(`Section "${sectionName}": parameter at index ${index} must be an object`); } const paramName = this.validateParamName(param, sectionName, index); const sectionDotParam = `${sectionName}.${paramName}`; this.requireNonEmptyStringField(param, sectionDotParam, 'display_name', IPARAM_DEFN_MAX_LENGTHS.DISPLAY_NAME); this.requireNonEmptyStringField(param, sectionDotParam, 'description', IPARAM_DEFN_MAX_LENGTHS.DESCRIPTION); this.validateType(param, sectionDotParam); if (param.required !== undefined && typeof param.required !== 'boolean') { throw new Error(`Parameter "${sectionDotParam}": "required" must be a boolean`); } this.validateOptions(param, sectionDotParam); if (param.type === 'SECRET' && param.default !== undefined && param.default !== null) { throw new Error(`Parameter "${sectionDotParam}": SECRET type cannot have a default value`); } this.validateDefaultValue(param, sectionDotParam); } private requireNonEmptyStringField( entity: Record, entityName: string, fieldName: string, maxLength?: number, entityLabel = 'Parameter' ): void { const value = entity[fieldName]; if (!validatorUtils.isNonEmptyString(value)) { throw new Error(`${entityLabel} "${entityName}": "${fieldName}" must be a non-empty string`); } const trimmed = value.trim(); if (maxLength !== undefined && trimmed.length > maxLength) { throw new Error(`${entityLabel} "${entityName}": "${fieldName}" cannot exceed ${maxLength} characters`); } } private rejectUnknownKeys(entity: object, allowedKeys: readonly string[], entityLabel: string): void { for (const key of Object.keys(entity)) { if (!allowedKeys.includes(key)) { throw new Error(`${entityLabel} must only contain ${allowedKeys.map((k) => `"${k}"`).join(', ')}`); } } } private validateSectionName(section: Record, index: number): string { const name = section['name']; if (!validatorUtils.isNonEmptyString(name)) { throw new Error(`Section at index ${index}: "name" must be a non-empty string`); } const sectionName = name.trim(); if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(sectionName)) { throw new Error( `Section "${sectionName}": "name" can only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore` ); } if (sectionName.length > IPARAM_DEFN_MAX_LENGTHS.NAME) { throw new Error( `Section "${sectionName}": "name" must be between 1 and ${IPARAM_DEFN_MAX_LENGTHS.NAME} characters` ); } return sectionName; } private validateParamName(param: Record, sectionName: string, index: number): string { const name = param['name']; if (!validatorUtils.isNonEmptyString(name)) { throw new Error(`Section "${sectionName}": parameter at index ${index}: "name" must be a non-empty string`); } const paramName = name.trim(); if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(paramName)) { throw new Error( `Parameter "${sectionName}.${paramName}": "name" can only contain letters, numbers, underscores, and hyphens, and must start with a letter or underscore` ); } if (paramName.length > IPARAM_DEFN_MAX_LENGTHS.NAME) { throw new Error( `Parameter "${sectionName}.${paramName}": "name" must be between 1 and ${IPARAM_DEFN_MAX_LENGTHS.NAME} characters` ); } return paramName; } private validateType(param: Record, sectionDotParam: string): void { const type = param['type']; if (!type || !VALID_IPARAM_TYPES.includes(type as IparamType)) { throw new Error(`Parameter "${sectionDotParam}": "type" must be one of: ${VALID_IPARAM_TYPES.join(', ')}`); } } private validateOptions(param: Record, sectionDotParam: string): void { const paramType = param['type'] as string; if (paramType !== 'DROPDOWN' && paramType !== 'MULTISELECT_DROPDOWN') { if (Object.prototype.hasOwnProperty.call(param, 'options')) { throw new Error( `Parameter "${sectionDotParam}": "options" is only allowed for DROPDOWN and MULTISELECT_DROPDOWN types` ); } return; } const options = param['options']; if (!options || !Array.isArray(options) || options.length === 0) { throw new Error( `Parameter "${sectionDotParam}": "options" is required and must be a non-empty array of strings for ${paramType} type` ); } if (options.length > OPTIONS_LIMITS.MAX_COUNT) { throw new Error( `Parameter "${sectionDotParam}": "options" cannot exceed ${OPTIONS_LIMITS.MAX_COUNT} items for ${paramType} type` ); } const seenTrimmedOptions = new Set(); for (let i = 0; i < options.length; i++) { if (typeof options[i] !== 'string') { throw new Error(`Parameter "${sectionDotParam}": option at index ${i} must be a string`); } const optionStr = (options[i] as string).trim(); if (optionStr === '') { throw new Error(`Parameter "${sectionDotParam}": option at index ${i} cannot be an empty string`); } if (seenTrimmedOptions.has(optionStr)) { throw new Error(`Parameter "${sectionDotParam}": "options" must not contain duplicate entries`); } seenTrimmedOptions.add(optionStr); if (optionStr.length > OPTIONS_LIMITS.MAX_OPTION_LENGTH) { throw new Error( `Parameter "${sectionDotParam}": option at index ${i} cannot exceed ${OPTIONS_LIMITS.MAX_OPTION_LENGTH} characters` ); } } } private validateDefaultValue(param: Record, sectionDotParam: string): void { const defaultValue = param['default']; if (defaultValue === undefined || defaultValue === null) return; if (typeof defaultValue === 'string' && defaultValue.trim() === '') { throw new Error( `Parameter "${sectionDotParam}": default value cannot be an empty string. Either remove the "default" key, set it to null, or provide a non-empty value.` ); } try { this.inputsValidator.validateParamValue(param as unknown as IparamDefn, defaultValue, sectionDotParam); } catch (error) { const msg = (error as Error).message.replace(/\bvalue\b/g, 'default value'); throw new Error(msg); } } }