< Summary - Envilder CLI

Information
Class: src/envilder/core/domain/MapFileUpdate.ts
Assembly: Default
File(s): src/envilder/core/domain/MapFileUpdate.ts
Tag: 561_41292037308
Line coverage
100%
Covered lines: 39
Uncovered lines: 0
Coverable lines: 39
Total lines: 160
Line coverage: 100%
Branch coverage
97%
Covered branches: 40
Total branches: 41
Branch coverage: 97.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/domain/MapFileUpdate.ts

#LineLine coverage
 1import type { MapFileConfig } from './MapFileConfig.js';
 2
 3export const MAP_FILE_SCHEMA_URL =
 154  'https://envilder.com/schema/map-file.v1.json';
 5
 6/**
 7 * Where a secret is pushed to. A profile is deliberately absent: it is a
 8 * local credential choice and never belongs in a committed map file.
 9 */
 10export type MapFileTarget = {
 11  provider: string;
 12  vaultUrl?: string;
 13};
 14
 15export type MapFileUpdateDecision =
 16  | { kind: 'create'; content: string }
 17  | { kind: 'append'; content: string }
 18  | { kind: 'unchanged' }
 19  | { kind: 'conflict' }
 20  | { kind: 'invalidJson' }
 21  | { kind: 'targetMismatch'; fileTarget: MapFileTarget };
 22
 23type Layout = {
 24  indent: string | number;
 25  newline: string;
 26  trailingNewline: boolean;
 27};
 28
 1529const DEFAULT_PROVIDER = 'aws';
 1530const DEFAULT_LAYOUT: Layout = {
 31  indent: 2,
 32  newline: '\n',
 33  trailingNewline: true,
 34};
 35
 36export function resolveMapFileTarget(config: MapFileConfig): MapFileTarget {
 4337  const provider = config.provider?.toLowerCase() || DEFAULT_PROVIDER;
 4338  if (provider === 'azure' && config.vaultUrl) {
 1239    return { provider, vaultUrl: config.vaultUrl };
 40  }
 3141  return { provider };
 42}
 43
 44/**
 45 * Decides how recording `key` -> `secretPath` changes a map file. Pure: it
 46 * never touches the filesystem, so every outcome can be checked on strings.
 47 *
 48 * A file that targets somewhere else than the push is left alone, and that
 49 * outranks a conflicting mapping, because the mapping then describes another
 50 * secret store.
 51 */
 52export function decideMapFileUpdate(
 53  existingContent: string | null,
 54  key: string,
 55  secretPath: string,
 56  target: MapFileTarget,
 57  mapFile: string,
 58): MapFileUpdateDecision {
 5259  if (existingContent === null) {
 2060    const file = buildNewFile(key, secretPath, target, mapFile);
 2061    return { kind: 'create', content: serialize(file, DEFAULT_LAYOUT) };
 62  }
 63
 3264  const parsed = parseObject(existingContent);
 3265  if (parsed === undefined) {
 366    return { kind: 'invalidJson' };
 67  }
 68
 2969  const fileTarget = resolveMapFileTarget(readConfig(parsed));
 2970  if (!sameTarget(fileTarget, target)) {
 671    return { kind: 'targetMismatch', fileTarget };
 72  }
 73
 2374  if (Object.hasOwn(parsed, key)) {
 675    return parsed[key] === secretPath
 76      ? { kind: 'unchanged' }
 77      : { kind: 'conflict' };
 78  }
 79
 80  // fromEntries defines own properties, so a `__proto__` key stays data.
 1781  const updated = Object.fromEntries([
 82    ...Object.entries(parsed),
 83    [key, secretPath],
 84  ]);
 1785  return {
 86    kind: 'append',
 87    content: serialize(updated, detectLayout(existingContent)),
 88  };
 89}
 90
 91/**
 92 * File names SchemaStore already associates with the map-file schema
 93 * (`envilder.json`, `envilder.*.json`), so editors validate them without a
 94 * `$schema` key.
 95 */
 1596const SCHEMA_STORE_FILE_NAME = /^envilder(?:\.[^.]+)*\.json$/;
 97
 98function buildNewFile(
 99  key: string,
 100  secretPath: string,
 101  target: MapFileTarget,
 102  mapFile: string,
 103): Record<string, unknown> {
 20104  const fileName = mapFile.split(/[\\/]/).pop() ?? mapFile;
 20105  const file: Record<string, unknown> = SCHEMA_STORE_FILE_NAME.test(fileName)
 106    ? {}
 107    : { $schema: MAP_FILE_SCHEMA_URL };
 20108  if (target.provider !== DEFAULT_PROVIDER) {
 3109    file.$config = {
 110      provider: target.provider,
 111      ...(target.vaultUrl && { vaultUrl: target.vaultUrl }),
 112    };
 113  }
 20114  file[key] = secretPath;
 20115  return file;
 116}
 117
 118function parseObject(content: string): Record<string, unknown> | undefined {
 32119  try {
 32120    const value: unknown = JSON.parse(content);
 32121    return typeof value === 'object' && value !== null && !Array.isArray(value)
 122      ? (value as Record<string, unknown>)
 123      : undefined;
 124  } catch {
 2125    return undefined;
 126  }
 127}
 128
 129function readConfig(parsed: Record<string, unknown>): MapFileConfig {
 29130  const config = parsed.$config;
 29131  return config && typeof config === 'object' ? (config as MapFileConfig) : {};
 132}
 133
 134function sameTarget(a: MapFileTarget, b: MapFileTarget): boolean {
 29135  return (
 136    a.provider === b.provider &&
 137    normalizeVaultUrl(a.vaultUrl) === normalizeVaultUrl(b.vaultUrl)
 138  );
 139}
 140
 141function normalizeVaultUrl(vaultUrl: string | undefined): string | undefined {
 48142  return vaultUrl?.trim().replace(/\/+$/, '').toLowerCase();
 143}
 144
 145function detectLayout(content: string): Layout {
 17146  const indent = /\n([ \t]+)\S/.exec(content)?.[1];
 17147  return {
 148    indent: indent ?? DEFAULT_LAYOUT.indent,
 149    newline: content.includes('\r\n') ? '\r\n' : '\n',
 150    trailingNewline: /\n$/.test(content),
 151  };
 152}
 153
 154function serialize(value: Record<string, unknown>, layout: Layout): string {
 37155  const body = JSON.stringify(value, null, layout.indent).replace(
 156    /\n/g,
 157    layout.newline,
 158  );
 37159  return layout.trailingNewline ? `${body}${layout.newline}` : body;
 160}