< Summary - Envilder CLI

Information
Class: src/envilder/core/infrastructure/variableStore/FileVariableStore.ts
Assembly: Default
File(s): src/envilder/core/infrastructure/variableStore/FileVariableStore.ts
Tag: 427_29134414720
Line coverage
98%
Covered lines: 90
Uncovered lines: 1
Coverable lines: 91
Total lines: 217
Line coverage: 98.9%
Branch coverage
94%
Covered branches: 52
Total branches: 55
Branch coverage: 94.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/infrastructure/variableStore/FileVariableStore.ts

#LineLine coverage
 1import * as fs from 'node:fs/promises';
 2import * as dotenv from 'dotenv';
 3import { inject, injectable } from 'inversify';
 4import {
 5  DependencyMissingError,
 6  EnvironmentFileError,
 7} from '../../domain/errors/DomainErrors.js';
 8import type {
 9  MapFileConfig,
 10  ParsedMapFile,
 11} from '../../domain/MapFileConfig.js';
 12import type { ILogger } from '../../domain/ports/ILogger.js';
 13import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 14import { TYPES } from '../../types.js';
 15
 16@injectable()
 817export class FileVariableStore implements IVariableStore {
 18  private logger: ILogger;
 19
 20  constructor(@inject(TYPES.ILogger) logger: ILogger) {
 5921    if (!logger) {
 122      throw new DependencyMissingError('Logger must be specified');
 23    }
 5824    this.logger = logger;
 25  }
 26
 27  async getMapping(source: string): Promise<Record<string, string>> {
 928    const { mappings } = await this.getParsedMapping(source);
 629    return mappings;
 30  }
 31
 32  async getParsedMapping(source: string): Promise<ParsedMapFile> {
 1433    const raw = await this.readJsonFile(source);
 1134    const { $config, ...rest } = raw;
 35    const config: MapFileConfig =
 1136      $config && typeof $config === 'object' ? $config : {};
 1437    const mappings: Record<string, string> = {};
 1438    for (const [key, value] of Object.entries(rest)) {
 2139      if (!key.startsWith('$') && typeof value === 'string') {
 1640        mappings[key] = value;
 41      }
 42    }
 1143    return { config, mappings };
 44  }
 45
 46  private async readJsonFile(source: string): Promise<Record<string, unknown>> {
 1447    try {
 1448      const content = await fs.readFile(source, 'utf-8');
 1249      try {
 1250        return JSON.parse(content);
 51      } catch (_err: unknown) {
 152        this.logger.error(`Error parsing JSON from ${source}`);
 153        throw new EnvironmentFileError(
 54          `Invalid JSON in parameter map file: ${source}`,
 55        );
 56      }
 57    } catch (error) {
 358      if (error instanceof EnvironmentFileError) {
 159        throw error;
 60      }
 261      throw new EnvironmentFileError(`Failed to read map file: ${source}`);
 62    }
 63  }
 64
 65  async getEnvironment(source: string): Promise<Record<string, string>> {
 866    const envVariables: Record<string, string> = {};
 867    try {
 868      await fs.access(source);
 69    } catch {
 470      return envVariables;
 71    }
 472    const existingEnvContent = await fs.readFile(source, 'utf-8');
 273    const parsedEnv = dotenv.parse(existingEnvContent) || {};
 874    Object.assign(envVariables, parsedEnv);
 75
 876    return envVariables;
 77  }
 78
 79  async saveEnvironment(
 80    destination: string,
 81    envVariables: Record<string, string>,
 82  ): Promise<void> {
 2183    const existingContent = await this.readExistingEnvContent(destination);
 2084    const envContent = this.buildEnvContent(existingContent, envVariables);
 85
 2086    try {
 2087      await fs.writeFile(destination, envContent);
 88    } catch (error) {
 89      const errorMessage =
 290        error instanceof Error ? error.message : String(error);
 291      this.logger.error(`Failed to write environment file: ${errorMessage}`);
 292      throw new EnvironmentFileError(
 93        `Failed to write environment file: ${errorMessage}`,
 94      );
 95    }
 96  }
 97
 98  private async readExistingEnvContent(
 99    destination: string,
 100  ): Promise<string | null> {
 21101    try {
 21102      return await fs.readFile(destination, 'utf-8');
 103    } catch (error) {
 11104      if (
 105        error instanceof Error &&
 106        (error as NodeJS.ErrnoException).code === 'ENOENT'
 107      ) {
 10108        return null;
 109      }
 1110      const message = error instanceof Error ? error.message : String(error);
 11111      this.logger.error(`Failed to read environment file: ${message}`);
 11112      throw new EnvironmentFileError(
 113        `Failed to read environment file: ${message}`,
 114      );
 115    }
 116  }
 117
 118  private buildEnvContent(
 119    existingContent: string | null,
 120    envVariables: Record<string, string>,
 121  ): string {
 20122    const pending = { ...envVariables };
 123
 20124    if (existingContent === null) {
 10125      return Object.entries(pending)
 10126        .map(([key, value]) => `${key}=${this.escapeEnvValue(value)}`)
 127        .join('\n');
 128    }
 129
 10130    const newline = existingContent.includes('\r\n') ? '\r\n' : '\n';
 20131    const hasTrailingNewline = /\r?\n$/.test(existingContent);
 20132    const lines = existingContent === '' ? [] : existingContent.split(/\r?\n/);
 20133    if (hasTrailingNewline) {
 1134      lines.pop();
 135    }
 136
 10137    const assignmentRegex = /^(\s*(?:export\s+)?)([\w.-]+)(\s*=\s*)(.*)$/;
 10138    const updatedKeys = new Set<string>();
 10139    const mergedLines = lines.map((line) => {
 19140      const match = assignmentRegex.exec(line);
 19141      if (match === null) {
 7142        return line;
 143      }
 12144      const [, prefix, key, separator, originalValue] = match;
 12145      if (!Object.hasOwn(pending, key)) {
 0146        return line;
 147      }
 12148      updatedKeys.add(key);
 12149      const value = this.formatValue(pending[key], originalValue);
 12150      return `${prefix}${key}${separator}${value}`;
 151    });
 10152    for (const key of updatedKeys) {
 11153      delete pending[key];
 154    }
 155
 10156    const appended = Object.entries(pending).map(
 3157      ([key, value]) => `${key}=${this.escapeEnvValue(value)}`,
 158    );
 159    const allLines =
 10160      appended.length > 0 ? [...mergedLines, ...appended] : mergedLines;
 20161    const result = allLines.join(newline);
 20162    return hasTrailingNewline ? result + newline : result;
 163  }
 164
 165  private formatValue(newValue: string, originalValue: string): string {
 12166    const trimmed = originalValue.trim();
 12167    const quote = trimmed[0];
 168    const isQuoted =
 12169      trimmed.length >= 2 &&
 170      (quote === '"' || quote === "'") &&
 171      trimmed[trimmed.length - 1] === quote;
 172    // Only keep the original quotes when the new value can be wrapped safely.
 173    // A value containing the same quote, a backslash, or a newline would
 174    // produce a string dotenv cannot parse back, so fall back to the
 175    // unquoted escaped form instead of corrupting the value.
 176    const isSafeToWrap =
 12177      !newValue.includes(quote) &&
 178      !newValue.includes('\\') &&
 179      !/[\r\n]/.test(newValue);
 12180    if (isQuoted && isSafeToWrap) {
 2181      return `${quote}${newValue}${quote}`;
 182    }
 10183    return this.escapeEnvValue(newValue);
 184  }
 185
 186  private escapeEnvValue(value: string): string {
 187    // codeql[js/incomplete-sanitization]
 188    // CodeQL flags this as incomplete sanitization because we don't escape backslashes
 189    // before newlines. However, this is intentional: the dotenv library does NOT
 190    // interpret escape sequences (it treats \n literally as backslash+n, not as a newline).
 191    // Therefore, escaping backslashes would actually break the functionality by
 192    // doubling them when read back by dotenv. This is not a security issue in this context.
 23193    return value.replace(/(\r\n|\n|\r)/g, '\\n');
 194  }
 195}
 196
 197export async function readMapFileConfig(
 198  mapPath: string,
 199): Promise<MapFileConfig> {
 4200  try {
 4201    const content = await fs.readFile(mapPath, 'utf-8');
 3202    try {
 3203      const raw = JSON.parse(content);
 3204      const config = raw.$config;
 3205      return config && typeof config === 'object' ? config : {};
 206    } catch {
 1207      throw new EnvironmentFileError(
 208        `Invalid JSON in parameter map file: ${mapPath}`,
 209      );
 210    }
 211  } catch (error) {
 2212    if (error instanceof EnvironmentFileError) {
 1213      throw error;
 214    }
 1215    throw new EnvironmentFileError(`Failed to read map file: ${mapPath}`);
 216  }
 217}