< Summary - Envilder CLI

Information
Class: src/envilder/core/domain/MapFileValidator.ts
Assembly: Default
File(s): src/envilder/core/domain/MapFileValidator.ts
Tag: 561_41292037308
Line coverage
100%
Covered lines: 75
Uncovered lines: 0
Coverable lines: 75
Total lines: 260
Line coverage: 100%
Branch coverage
98%
Covered branches: 56
Total branches: 57
Branch coverage: 98.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/domain/MapFileValidator.ts

#LineLine coverage
 1import {
 2  invalidEnvironmentVariableNameMessage,
 3  isValidEnvironmentVariableName,
 4  quoteName,
 5} from './EnvironmentVariableName.js';
 6import type { MapFileConfig } from './MapFileConfig.js';
 7import {
 8  DEFAULT_PROVIDER,
 9  fieldOutsideProviderMessage,
 10  PROVIDER_SPECIFIC_FIELDS,
 11  REQUIRED_PROVIDER_FIELDS,
 12  requiredFieldMessage,
 13  unsupportedProviderMessage,
 14} from './ProviderConfiguration.js';
 15
 16/**
 17 * One reason a map file cannot be used. `key` names the offending entry
 18 * (`DB_URL`, `$config.profile`, `--provider`, or `(root)` for the document
 19 * itself), and `message` describes it on its own. Neither ever carries a
 20 * mapped value.
 21 */
 22export type MapFileViolation = {
 23  readonly key: string;
 24  readonly message: string;
 25};
 26
 27/**
 28 * What the map file is checked against besides the schema: the provider
 29 * options given outside the file, which take precedence over `$config` as they
 30 * do on a run, and the providers the caller can actually use.
 31 */
 32export type MapFileValidationContext = {
 33  readonly overrides: MapFileConfig;
 34  readonly supportedProviders: readonly string[];
 35};
 36
 937const CONFIG_FIELDS = [
 38  'provider',
 39  'profile',
 40  'vaultUrl',
 41  'projectId',
 42  'namespace',
 43  'path',
 44  'name',
 45  'description',
 46  'owner',
 47  'environment',
 48];
 49
 950const URI_FIELDS = new Set(['vaultUrl']);
 51
 952const OVERRIDE_FLAGS: Readonly<Record<string, string>> = {
 53  provider: '--provider',
 54  profile: '--profile',
 55  vaultUrl: '--vault-url',
 56};
 57
 58/**
 59 * Checks a parsed map file against `spec/map-file.v1.json` and against the
 60 * provider configuration it resolves to, and returns every violation found
 61 * (an empty list when the file is usable). The cross-provider rules run on the
 62 * configuration a run would use, so a flag can both fix and break it.
 63 */
 64export function validateMapFile(
 65  document: unknown,
 66  context: MapFileValidationContext,
 67): MapFileViolation[] {
 8268  if (!isJsonObject(document)) {
 369    return [{ key: '(root)', message: 'The map file must be a JSON object.' }];
 70  }
 71
 7972  const violations: MapFileViolation[] = [];
 7973  let fileConfig: Record<string, unknown> = {};
 7974  for (const [key, value] of Object.entries(document)) {
 11675    if (key === '$schema') {
 576      violations.push(...validateUri('$schema', value));
 11177    } else if (key === '$config') {
 5278      if (isJsonObject(value)) {
 5079        fileConfig = value;
 5080        violations.push(...validateConfigFields(value));
 81      } else {
 282        violations.push({
 83          key: '$config',
 84          message: '$config must be an object.',
 85        });
 86      }
 5987    } else if (key.startsWith('$')) {
 288      violations.push({
 89        key,
 90        message: `${formatKey(key)} is reserved: only $schema and $config may start with "$".`,
 91      });
 92    } else {
 5793      violations.push(...validateMapping(key, value));
 94    }
 95  }
 96
 7997  violations.push(...validateProviderConfiguration(fileConfig, context));
 7998  return violations;
 99}
 100
 101function validateMapping(key: string, value: unknown): MapFileViolation[] {
 57102  const violations: MapFileViolation[] = [];
 57103  if (!isValidEnvironmentVariableName(key)) {
 6104    violations.push({
 105      key,
 106      message: `${invalidEnvironmentVariableNameMessage(key)}.`,
 107    });
 108  }
 57109  if (typeof value !== 'string') {
 110    // The type only: the value itself may be a secret pasted by mistake.
 8111    violations.push({
 112      key,
 113      message: `${formatKey(key)} must map to a string secret path, not ${describeType(value)}.`,
 114    });
 115  }
 57116  return violations;
 117}
 118
 119function validateConfigFields(
 120  config: Record<string, unknown>,
 121): MapFileViolation[] {
 50122  const violations: MapFileViolation[] = [];
 50123  for (const [field, value] of Object.entries(config)) {
 84124    const key = `$config.${field}`;
 84125    if (!CONFIG_FIELDS.includes(field)) {
 3126      violations.push({
 127        key,
 128        message: `${formatKey(key)} is not a known field. Allowed fields: ${CONFIG_FIELDS.join(', ')}.`,
 129      });
 81130    } else if (typeof value !== 'string') {
 2131      violations.push({
 132        key,
 133        message: `${key} must be a string, not ${describeType(value)}.`,
 134      });
 79135    } else if (URI_FIELDS.has(field)) {
 16136      violations.push(...validateUri(key, value));
 137    }
 138  }
 50139  return violations;
 140}
 141
 142function validateUri(key: string, value: unknown): MapFileViolation[] {
 21143  if (typeof value !== 'string') {
 1144    return [
 145      { key, message: `${key} must be a string, not ${describeType(value)}.` },
 146    ];
 147  }
 20148  if (!isUri(value)) {
 2149    return [{ key, message: `${key} must be an absolute URI.` }];
 150  }
 18151  return [];
 152}
 153
 154/** A configuration field with the key that named it, for the report. */
 155type SourcedValue = { readonly value: string; readonly source: string };
 156
 157/**
 158 * Resolves the configuration a run would use (flags over `$config`, `aws`
 159 * when no provider is named) and applies the cross-provider rules to it.
 160 * Fields with the wrong type are left to the schema checks above.
 161 */
 162function validateProviderConfiguration(
 163  fileConfig: Record<string, unknown>,
 164  context: MapFileValidationContext,
 165): MapFileViolation[] {
 79166  const effective = resolveEffectiveConfig(fileConfig, context.overrides);
 79167  const provider = effective.provider ?? {
 168    value: DEFAULT_PROVIDER,
 169    source: '$config.provider',
 170  };
 171
 79172  if (!context.supportedProviders.includes(provider.value)) {
 6173    return [
 174      {
 175        key: provider.source,
 176        message: `${unsupportedProviderMessage(formatKey(provider.value), context.supportedProviders)}.`,
 177      },
 178    ];
 179  }
 180
 73181  const violations: MapFileViolation[] = [];
 73182  for (const [field, validProviders] of Object.entries(
 183    PROVIDER_SPECIFIC_FIELDS,
 184  )) {
 365185    const setting = effective[field];
 365186    if (setting === undefined || validProviders.includes(provider.value)) {
 348187      continue;
 188    }
 17189    const usable = validProviders.filter((name) =>
 25190      context.supportedProviders.includes(name),
 191    );
 17192    violations.push({
 193      key: setting.source,
 194      message: fieldOutsideProviderMessage(
 195        setting.source,
 196        usable.length > 0 ? usable : validProviders,
 197        provider.value,
 198      ),
 199    });
 200  }
 201
 73202  const required = REQUIRED_PROVIDER_FIELDS[provider.value];
 73203  if (required !== undefined && effective[required] === undefined) {
 7204    violations.push({
 205      key: `$config.${required}`,
 206      message: requiredFieldMessage(required, provider.value),
 207    });
 208  }
 73209  return violations;
 210}
 211
 212function resolveEffectiveConfig(
 213  fileConfig: Record<string, unknown>,
 214  overrides: MapFileConfig,
 215): Record<string, SourcedValue | undefined> {
 79216  const effective: Record<string, SourcedValue | undefined> = {};
 79217  for (const field of CONFIG_FIELDS) {
 790218    const value = fileConfig[field];
 790219    if (typeof value === 'string') {
 79220      effective[field] = { value, source: `$config.${field}` };
 221    }
 222  }
 223  // Empty flags are ignored, as they are on a run.
 79224  for (const [field, flag] of Object.entries(OVERRIDE_FLAGS)) {
 237225    const value = overrides[field as keyof MapFileConfig];
 237226    if (value) {
 10227      effective[field] = {
 228        value: field === 'provider' ? value.toLowerCase() : value,
 229        source: flag,
 230      };
 231    }
 232  }
 79233  return effective;
 234}
 235
 236function isJsonObject(value: unknown): value is Record<string, unknown> {
 134237  return typeof value === 'object' && value !== null && !Array.isArray(value);
 238}
 239
 240function isUri(value: string): boolean {
 20241  return /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/.test(value) && URL.canParse(value);
 242}
 243
 244function describeType(value: unknown): string {
 11245  if (value === null) {
 1246    return 'null';
 247  }
 10248  if (Array.isArray(value)) {
 1249    return 'an array';
 250  }
 9251  return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
 252}
 253
 254/**
 255 * Keys are untrusted input: anything outside a plain identifier is quoted, so
 256 * a key carrying a line break cannot split the report.
 257 */
 258function formatKey(key: string): string {
 19259  return /^[A-Za-z0-9_$./-]+$/.test(key) ? key : quoteName(key);
 260}