< Summary - Envilder CLI

Information
Class: src/envilder/core/infrastructure/variableStore/FileVariableStore.ts
Assembly: Default
File(s): src/envilder/core/infrastructure/variableStore/FileVariableStore.ts
Tag: 502_37137557711
Line coverage
96%
Covered lines: 154
Uncovered lines: 5
Coverable lines: 159
Total lines: 552
Line coverage: 96.8%
Branch coverage
89%
Covered branches: 84
Total branches: 94
Branch coverage: 89.3%
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  invalidEnvironmentVariableNameMessage,
 6  isValidEnvironmentVariableName,
 7} from '../../domain/EnvironmentVariableName.js';
 8import {
 9  DependencyMissingError,
 10  EnvironmentFileError,
 11  InvalidArgumentError,
 12} from '../../domain/errors/DomainErrors.js';
 13import type {
 14  MapFileConfig,
 15  ParsedMapFile,
 16} from '../../domain/MapFileConfig.js';
 17import type { ILogger } from '../../domain/ports/ILogger.js';
 18import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 19import { TYPES } from '../../types.js';
 20
 21/** The delimiters dotenv recognizes for quoting a `.env` value. */
 822const ENV_QUOTES = ["'", '"', '`'] as const;
 23type EnvQuote = (typeof ENV_QUOTES)[number];
 24
 25/**
 26 * Follows dotenv's own `LINE` grammar rather than re-deriving it, split into
 27 * capture groups, with the two deliberate narrowings noted at the end:
 28 *
 29 *     /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'
 30 *       |\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
 31 *
 32 * Approximating that grammar instead of copying it is what repeatedly left old
 33 * secrets on disk: every form dotenv spans and we do not degrades into the
 34 * single-line branch, which rewrites the first physical line of an assignment
 35 * and abandons the rest of the previous value in the file. Cases that cost us a
 36 * round each — a closing delimiter followed by blanks, a colon separator, a
 37 * value that only starts on the next physical line — are all consequences of
 38 * the same divergence, so the grammar is now shared rather than re-derived.
 39 *
 40 * Everything except the value is captured and re-emitted verbatim, so groups
 41 * whose `\s` reaches across line breaks cannot lose structure: whatever they
 42 * consume, they put back.
 43 *
 44 * `(?<!\r)$` is the one place the raw text needs more than dotenv's grammar
 45 * says. dotenv rewrites every CRLF to LF before it matches, so it never sees
 46 * the position between a CR and its LF; we match the file as it is, where `$`
 47 * does match there and would let a separator whose `\s` had just taken the CR
 48 * end the assignment early, leaving the rest of the value behind a bare
 49 * carriage return.
 50 *
 51 * Two places stay deliberately narrower than dotenv, because dotenv only has to
 52 * find where a value ends while we also have to put the surroundings back. The
 53 * trailing padding is horizontal, so a comment on the next line stays outside
 54 * the span and the break before it is still visible as structure; and the
 55 * unquoted run is lazy, so the blanks before an inline comment land in the
 56 * padding we re-emit instead of inside the value we replace. Neither narrows
 57 * the value span itself, which is the part that has to agree with dotenv.
 58 */
 59const ASSIGNMENT_PATTERN =
 860  /^(\s*(?:export\s+)?)([\w.-]+)(\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]*?)([^
 61
 62/** The line break that closes a file, kept verbatim instead of normalized. */
 863const TRAILING_NEWLINE_PATTERN = /(?:\r\n|[\r\n])$/;
 64
 65@injectable()
 866export class FileVariableStore implements IVariableStore {
 67  private logger: ILogger;
 68
 69  constructor(@inject(TYPES.ILogger) logger: ILogger) {
 16870    if (!logger) {
 171      throw new DependencyMissingError('Logger must be specified');
 72    }
 16773    this.logger = logger;
 74  }
 75
 76  async getMapping(source: string): Promise<Record<string, string>> {
 2677    const { mappings } = await this.getParsedMapping(source);
 678    return mappings;
 79  }
 80
 81  async getParsedMapping(source: string): Promise<ParsedMapFile> {
 3282    const raw = await this.readJsonFile(source);
 2983    const { $config, ...rest } = raw;
 84    const config: MapFileConfig =
 2985      $config && typeof $config === 'object' ? $config : {};
 86    // Null-prototype: map-file keys are untrusted, and a plain object literal
 87    // would route `__proto__` through the Object.prototype setter, silently
 88    // dropping that mapping instead of storing it as data.
 3289    const mappings: Record<string, string> = Object.create(null);
 3290    for (const [key, value] of Object.entries(rest)) {
 3991      if (key.startsWith('$')) {
 192        continue;
 93      }
 94      // Before the non-string filter, so the name rule covers every mapping
 95      // key the file declares. Validating after would accept a name the
 96      // published schema rejects whenever its value happened to be a number
 97      // or an object. Non-string values are still skipped, not an error.
 3898      this.assertValidVariableName(key);
 3899      if (typeof value !== 'string') {
 4100        continue;
 101      }
 16102      mappings[key] = value;
 103    }
 11104    return { config, mappings };
 105  }
 106
 107  private assertValidVariableName(name: string): void {
 174108    if (!isValidEnvironmentVariableName(name)) {
 34109      throw new InvalidArgumentError(
 110        invalidEnvironmentVariableNameMessage(name),
 111      );
 112    }
 113  }
 114
 115  private async readJsonFile(source: string): Promise<Record<string, unknown>> {
 32116    try {
 32117      const content = await fs.readFile(source, 'utf-8');
 30118      try {
 30119        return JSON.parse(content);
 120      } catch (_err: unknown) {
 1121        this.logger.error(`Error parsing JSON from ${source}`);
 1122        throw new EnvironmentFileError(
 123          `Invalid JSON in parameter map file: ${source}`,
 124        );
 125      }
 126    } catch (error) {
 3127      if (error instanceof EnvironmentFileError) {
 1128        throw error;
 129      }
 2130      throw new EnvironmentFileError(`Failed to read map file: ${source}`);
 131    }
 132  }
 133
 134  async getEnvironment(source: string): Promise<Record<string, string>> {
 135    // Null-prototype for the same reason as the parsed mappings: callers
 136    // assign resolved secrets straight into this map, and a plain object
 137    // would route a prototype-shadowing key through an inherited setter.
 8138    const envVariables: Record<string, string> = Object.create(null);
 8139    try {
 8140      await fs.access(source);
 141    } catch {
 4142      return envVariables;
 143    }
 4144    const existingEnvContent = await fs.readFile(source, 'utf-8');
 2145    const parsedEnv = dotenv.parse(existingEnvContent) || {};
 8146    Object.assign(envVariables, parsedEnv);
 147
 8148    return envVariables;
 149  }
 150
 151  async saveEnvironment(
 152    destination: string,
 153    envVariables: Record<string, string>,
 154  ): Promise<void> {
 103155    for (const key of Object.keys(envVariables)) {
 136156      this.assertValidVariableName(key);
 157    }
 158
 87159    const existingContent = await this.readExistingEnvContent(destination);
 86160    const unmanagedValues = this.collectUnmanagedValues(
 161      existingContent,
 162      envVariables,
 163    );
 86164    const envContent = this.buildEnvContent(existingContent, envVariables);
 86165    this.assertValuesArePreserved(envContent, envVariables, unmanagedValues);
 166
 86167    try {
 86168      await fs.writeFile(destination, envContent);
 169    } catch (error) {
 170      const errorMessage =
 2171        error instanceof Error ? error.message : String(error);
 2172      this.logger.error(`Failed to write environment file: ${errorMessage}`);
 2173      throw new EnvironmentFileError(
 174        `Failed to write environment file: ${errorMessage}`,
 175      );
 176    }
 177  }
 178
 179  private async readExistingEnvContent(
 180    destination: string,
 181  ): Promise<string | null> {
 87182    try {
 87183      return await fs.readFile(destination, 'utf-8');
 184    } catch (error) {
 16185      if (
 186        error instanceof Error &&
 187        (error as NodeJS.ErrnoException).code === 'ENOENT'
 188      ) {
 15189        return null;
 190      }
 1191      const message = error instanceof Error ? error.message : String(error);
 16192      this.logger.error(`Failed to read environment file: ${message}`);
 16193      throw new EnvironmentFileError(
 194        `Failed to read environment file: ${message}`,
 195      );
 196    }
 197  }
 198
 199  private buildEnvContent(
 200    existingContent: string | null,
 201    envVariables: Record<string, string>,
 202  ): string {
 86203    const entries = Object.entries(envVariables);
 204    // A file we create ends with a line break, as a POSIX text file should:
 205    // without one, git reports "\ No newline at end of file" and any editor
 206    // configured to add one rewrites the file behind us. An existing file that
 207    // is empty counts as one we create, since there is no shape to preserve;
 208    // one with content keeps the ending it has, missing ending included, because
 209    // that shape belongs to whoever wrote it.
 86210    if (existingContent === null || existingContent === '') {
 17211      const created = this.renderAssignments(entries).join('\n');
 17212      return created === '' ? created : `${created}\n`;
 213    }
 214
 215    // The body keeps its own line breaks: rewriting them would also rewrite the
 216    // line breaks a multiline value carries as payload.
 217    const trailingNewline =
 69218      TRAILING_NEWLINE_PATTERN.exec(existingContent)?.[0] ?? '';
 86219    const body = existingContent.slice(
 220      0,
 221      existingContent.length - trailingNewline.length,
 222    );
 86223    const newline = this.detectStructuralNewline(body, trailingNewline);
 224
 86225    const updatedKeys = new Set<string>();
 86226    const merged = body.replace(
 227      ASSIGNMENT_PATTERN,
 228      (
 229        assignment: string,
 230        prefix: string,
 231        key: string,
 232        separator: string,
 233        rawValue: string | undefined,
 234        padding: string,
 235        comment: string,
 236      ) => {
 161237        if (!Object.hasOwn(envVariables, key)) {
 77238          return assignment;
 239        }
 84240        updatedKeys.add(key);
 241        // An assignment that already says the right thing is left exactly as
 242        // it is. The CLI hands us every key it read, not just the mapped ones,
 243        // so rewriting on sight would reserialize untouched entries and churn
 244        // bytes no reader can see: a CRLF carried inside a value, a span that
 245        // spreads over two physical lines. Leaving them also makes a run with
 246        // nothing to change produce a byte-identical file.
 84247        if (this.parsesBackTo(key, rawValue ?? '', envVariables[key])) {
 5248          return assignment;
 249        }
 250        // dotenv's `=\s*?` is lazy, so the blanks after it belong to the value
 251        // group. Put back the ones that stayed on the line, which is the
 252        // spacing the file chose; a value that only began on a later physical
 253        // line collapses onto the key's line instead, because that is how the
 254        // old one leaves.
 79255        const leading = /^\s*/.exec(rawValue ?? '')?.[0] ?? '';
 161256        const spacing = /[\r\n]/.test(leading) ? '' : leading;
 161257        const value = this.serializeAssignmentValue(
 258          key,
 259          envVariables[key],
 260          rawValue ?? '',
 261        );
 161262        return `${prefix}${key}${separator}${spacing}${value}${padding}${comment}`;
 263      },
 264    );
 265
 86266    this.assertNoManagedAssignmentSurvives(merged, envVariables);
 86267    const appended = this.renderAssignments(
 90268      entries.filter(([key]) => !updatedKeys.has(key)),
 269    );
 86270    const content = [merged, ...appended].join(newline);
 86271    return trailingNewline === '' ? content : content + trailingNewline;
 272  }
 273
 274  /**
 275   * The break that separates two assignments, which is the only kind that is
 276   * structural. Blanking the claimed spans leaves exactly those: a break
 277   * carried inside a value is part of a span and disappears with it, while a
 278   * break that precedes a span is kept, because the prefix reaches back over
 279   * blank lines and over the break that ended the line before. The terminator
 280   * that closes the file is structural by the same argument and wins when the
 281   * file has one; scanning the raw text for any CRLF is the last resort, and
 282   * only a guess.
 283   */
 284  private detectStructuralNewline(
 285    body: string,
 286    trailingNewline: string,
 287  ): string {
 69288    if (trailingNewline !== '') {
 30289      return trailingNewline;
 290    }
 39291    const betweenSpans = body.replace(
 292      ASSIGNMENT_PATTERN,
 293      (assignment: string, prefix: string) =>
 294        // The breaks a span holds in its prefix are the ones that separate it
 295        // from what came before, so they survive the blanking; everything else
 296        // it holds, line breaks included, is payload and goes.
 91297        prefix.replace(/[^\r\n]/g, ' ') +
 298        ' '.repeat(assignment.length - prefix.length),
 299    );
 39300    return (
 301      /\r\n|[\r\n]/.exec(betweenSpans)?.[0] ??
 302      (body.includes('\r\n') ? '\r\n' : '\n')
 303    );
 304  }
 305
 306  /**
 307   * That the pattern and dotenv agree on where an assignment ends is an
 308   * invariant, so assert it rather than assume it. Whatever the merge pass did
 309   * not claim is read the way dotenv reads it, and a managed key still in there
 310   * is an assignment dotenv can see and we missed. Appending the new value
 311   * would satisfy a reader, because dotenv keeps the last duplicate, while the
 312   * previous secret stayed on disk and `assertValuesArePreserved` saw nothing
 313   * wrong, so refuse the write instead.
 314   *
 315   * No supported syntax reaches this today: the grammar is shared with dotenv
 316   * precisely so that none can, and every form that once did — duplicates, a
 317   * colon separator, a value starting on the next physical line — is claimed
 318   * and replaced. This is the net for the day the two drift apart, which
 319   * declaring `dotenv` as `^17.4.2` leaves open.
 320   */
 321  private assertNoManagedAssignmentSurvives(
 322    merged: string,
 323    envVariables: Record<string, string>,
 324  ): void {
 325    // Blank the claimed spans in place: dropping them would join the text
 326    // around them and invent assignments that were never there.
 69327    const remainder = merged.replace(ASSIGNMENT_PATTERN, (assignment) =>
 161328      assignment.replace(/[^\r\n]/g, ' '),
 329    );
 69330    const stale = Object.keys(dotenv.parse(remainder))
 0331      .filter((key) => Object.hasOwn(envVariables, key))
 0332      .map((key) => `"${key}"`);
 69333    if (stale.length === 0) {
 69334      return;
 335    }
 336    // Names only: the values at stake are the secrets this guard protects.
 0337    throw new EnvironmentFileError(
 338      `Cannot locate every existing assignment for ${stale.join(', ')}; the previous value would stay in the file, so no
 339    );
 340  }
 341
 342  private renderAssignments(entries: Array<[string, string]>): string[] {
 86343    return entries.map(
 37344      ([key, value]) => `${key}=${this.serializeAssignmentValue(key, value)}`,
 345    );
 346  }
 347
 348  /**
 349   * Serializes a value for a single `.env` assignment. Candidate
 350   * representations are tried in order of preference and accepted only once
 351   * `dotenv.parse` confirms the assignment yields exactly the intended
 352   * key/value, never by inspection alone. An existing delimiter (from
 353   * `originalValue`, when updating an existing assignment) is preferred but
 354   * only kept when it still round-trips for the new value.
 355   */
 356  private serializeAssignmentValue(
 357    key: string,
 358    value: string,
 359    originalValue?: string,
 360  ): string {
 116361    const preferredQuote = this.detectExistingQuote(originalValue);
 116362    for (const candidate of this.buildValueCandidates(value, preferredQuote)) {
 125363      if (this.parsesBackTo(key, candidate, value)) {
 115364        return candidate;
 365      }
 366    }
 1367    throw new EnvironmentFileError(
 368      `Cannot represent the value for "${key}" in a dotenv-compatible format without data loss`,
 369    );
 370  }
 371
 372  private buildValueCandidates(
 373    value: string,
 374    preferredQuote: EnvQuote | undefined,
 375  ): string[] {
 116376    const hasLineBreak = /[\r\n]/.test(value);
 377    // Double quotes come first for multiline values because they are the only
 378    // delimiter whose escapes keep the assignment on one physical line.
 116379    const quotes: readonly EnvQuote[] = hasLineBreak
 380      ? ['"', "'", '`']
 381      : ENV_QUOTES;
 116382    const candidates: string[] = [];
 116383    if (preferredQuote !== undefined) {
 42384      candidates.push(...this.buildQuotedCandidates(value, preferredQuote));
 385    }
 116386    if (!hasLineBreak) {
 110387      candidates.push(value);
 388    }
 116389    for (const quote of quotes) {
 348390      candidates.push(...this.buildQuotedCandidates(value, quote));
 391    }
 116392    return candidates;
 393  }
 394
 395  private buildQuotedCandidates(value: string, quote: EnvQuote): string[] {
 390396    if (!this.isDelimiterSafe(value, quote)) {
 18397      return [];
 398    }
 372399    const raw = `${quote}${value}${quote}`;
 372400    if (quote !== '"') {
 246401      return [raw];
 402    }
 403    // dotenv expands `\n`/`\r` only inside double quotes, so the encoded form
 404    // is the one that survives real line breaks and the raw form is the one
 405    // that survives values holding those sequences literally.
 126406    return [`"${this.encodeLineBreaks(value)}"`, raw];
 407  }
 408
 409  /**
 410   * dotenv consumes `\<delimiter>` as a single unit inside a quoted value, so
 411   * an escaped-looking delimiter stays literal while a bare one would close
 412   * the value early.
 413   */
 414  private isDelimiterSafe(value: string, quote: EnvQuote): boolean {
 390415    for (
 390416      let index = value.indexOf(quote);
 417      index !== -1;
 418      index = value.indexOf(quote, index + 1)
 419    ) {
 20420      if (value[index - 1] !== '\\') {
 18421        return false;
 422      }
 423    }
 372424    return true;
 425  }
 426
 427  private encodeLineBreaks(value: string): string {
 126428    return value.replace(/[\r\n]/g, (match) =>
 6429      match === '\n' ? '\\n' : '\\r',
 430    );
 431  }
 432
 433  private detectExistingQuote(
 434    originalValue: string | undefined,
 435  ): EnvQuote | undefined {
 116436    if (originalValue === undefined) {
 37437      return undefined;
 438    }
 79439    const trimmed = originalValue.trim();
 79440    const quote = trimmed[0];
 441    const isQuoted =
 79442      trimmed.length >= 2 &&
 443      (ENV_QUOTES as readonly string[]).includes(quote) &&
 444      trimmed[trimmed.length - 1] === quote;
 116445    return isQuoted ? (quote as EnvQuote) : undefined;
 446  }
 447
 448  private parsesBackTo(
 449    key: string,
 450    candidate: string,
 451    expected: string,
 452  ): boolean {
 209453    const parsed = dotenv.parse(`${key}=${candidate}\n`);
 209454    const parsedKeys = Object.keys(parsed);
 209455    return (
 456      parsedKeys.length === 1 &&
 457      parsedKeys[0] === key &&
 458      parsed[key] === expected
 459    );
 460  }
 461
 462  /** Keys the file no longer reads back as the value they are meant to hold. */
 463  private keysThatLostTheirValue(
 464    parsed: Record<string, string>,
 465    expected: Record<string, string>,
 466  ): string[] {
 194467    return Object.keys(expected).filter((key) => parsed[key] !== expected[key]);
 468  }
 469
 470  /**
 471   * Keys the file gained. Nobody asked for them, so they can only come from our
 472   * grammar and dotenv's disagreeing about where some value ended.
 473   */
 474  private keysThatAppeared(
 475    parsed: Record<string, string>,
 476    ...accounted: Array<Record<string, string>>
 477  ): string[] {
 85478    return Object.keys(parsed).filter(
 271479      (key) => !accounted.some((group) => Object.hasOwn(group, key)),
 480    );
 481  }
 482
 483  /**
 484   * The values the file already held for keys this run is not writing. Read
 485   * through `dotenv.parse` so duplicate keys collapse the same way a consumer
 486   * would see them.
 487   */
 488  private collectUnmanagedValues(
 489    existingContent: string | null,
 490    envVariables: Record<string, string>,
 491  ): Record<string, string> {
 86492    if (existingContent === null) {
 15493      return {};
 494    }
 71495    const unmanaged: Record<string, string> = {};
 71496    for (const [key, value] of Object.entries(dotenv.parse(existingContent))) {
 159497      if (!Object.hasOwn(envVariables, key)) {
 77498        unmanaged[key] = value;
 499      }
 500    }
 71501    return unmanaged;
 502  }
 503
 504  /**
 505   * Reads the result back with dotenv before it reaches disk: every managed key
 506   * must yield the value we were asked to store, every key we did not manage
 507   * must yield the value the file already had, and no third key may appear
 508   * because our grammar and dotenv's disagreed on where a value ended.
 509   */
 510  private assertValuesArePreserved(
 511    content: string,
 512    envVariables: Record<string, string>,
 513    unmanagedValues: Record<string, string>,
 514  ): void {
 85515    const parsed = dotenv.parse(content);
 85516    const affectedKeys = new Set([
 517      ...this.keysThatLostTheirValue(parsed, envVariables),
 518      ...this.keysThatLostTheirValue(parsed, unmanagedValues),
 519      ...this.keysThatAppeared(parsed, envVariables, unmanagedValues),
 520    ]);
 85521    if (affectedKeys.size === 0) {
 85522      return;
 523    }
 524    // Names only: the values at stake are the secrets this guard protects.
 0525    const keys = [...affectedKeys].map((key) => `"${key}"`).join(', ');
 0526    throw new EnvironmentFileError(
 527      `Cannot write the environment file without losing or altering ${keys}; nothing was written`,
 528    );
 529  }
 530}
 531
 532export async function readMapFileConfig(
 533  mapPath: string,
 534): Promise<MapFileConfig> {
 4535  try {
 4536    const content = await fs.readFile(mapPath, 'utf-8');
 3537    try {
 3538      const raw = JSON.parse(content);
 3539      const config = raw.$config;
 3540      return config && typeof config === 'object' ? config : {};
 541    } catch {
 1542      throw new EnvironmentFileError(
 543        `Invalid JSON in parameter map file: ${mapPath}`,
 544      );
 545    }
 546  } catch (error) {
 2547    if (error instanceof EnvironmentFileError) {
 1548      throw error;
 549    }
 1550    throw new EnvironmentFileError(`Failed to read map file: ${mapPath}`);
 551  }
 552}