< Summary - Envilder CLI

Information
Class: src/envilder/core/domain/EnvironmentVariableName.ts
Assembly: Default
File(s): src/envilder/core/domain/EnvironmentVariableName.ts
Tag: 561_41292037308
Line coverage
100%
Covered lines: 13
Uncovered lines: 0
Coverable lines: 13
Total lines: 115
Line coverage: 100%
Branch coverage
100%
Covered branches: 9
Total branches: 9
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/domain/EnvironmentVariableName.ts

#LineLine coverage
 1/**
 2 * Pure domain rule describing which strings are usable as environment variable
 3 * names. Centralized here so every layer that accepts a name coming from a map
 4 * file or caller input (entity construction, mapping parsing, `.env` writing)
 5 * shares a single source of truth instead of duplicating the same regex and
 6 * message.
 7 *
 8 * The rule enforces one invariant: **an accepted name must survive a `.env`
 9 * round-trip as itself and nothing else.**
 10 *
 11 * That is expressed as an allowlist rather than a list of banned characters,
 12 * because a blocklist cannot hold the invariant. Anything outside
 13 * `[A-Za-z0-9_./-]+` either makes the line unparseable, so the variable is
 14 * written and then silently lost (a space, `#`, a tab, an accented or
 15 * non-Latin letter, NUL, vertical tab), or is read back as a *different*
 16 * assignment, which is the injection in issue #511:
 17 *
 18 * - `=` opens a second assignment on the same line.
 19 * - CR and LF open a second line.
 20 * - U+2028 and U+2029 are JavaScript line terminators. The `.env` writer does
 21 *   not split on them, but `dotenv` parses lines with a multiline regex whose
 22 *   `^` matches after them, so a name embedding one is read back as a
 23 *   separate assignment.
 24 *
 25 * Dotted and hyphenated names (historically accepted, non-POSIX) stay valid.
 26 * So does `/`, which map files have always used for hierarchical names
 27 * (`Database/Password`): it cannot inject anything, and envilder writes and
 28 * reads it back as itself. Some readers still skip such a line, which
 29 * {@link isPortableEnvFileName} reports.
 30 */
 2531const READABLE_NAME = /^[A-Za-z0-9_./-]+$/;
 32
 33/**
 34 * The key grammar every common `.env` reader shares. A name outside it but
 35 * inside {@link READABLE_NAME} (one holding `/`) round-trips through envilder
 36 * and `python-dotenv`, while `dotenv` for Node.js skips the line and a POSIX
 37 * shell cannot `source` it.
 38 */
 2539const PORTABLE_ENV_FILE_NAME = /^[A-Za-z0-9_.-]+$/;
 40
 41/**
 42 * `__proto__` is the one name inside the allowlist that still breaks the
 43 * round-trip. Writing it is fine, but every reader builds a plain object and
 44 * assigns into it, which routes the key through the `Object.prototype`
 45 * accessor instead of creating an own property -- `dotenv.parse` included, so
 46 * `dotenv.parse('__proto__=v')` returns `{}`. Accepting the name would mean
 47 * resolving the secret and writing a line that neither envilder nor any
 48 * dotenv-based consumer can read back, while reporting success.
 49 */
 2550const UNSUPPORTED_NAMES = new Set(['__proto__']);
 51
 52export function isValidEnvironmentVariableName(name: string): boolean {
 45253  return (
 54    typeof name === 'string' &&
 55    READABLE_NAME.test(name) &&
 56    !UNSUPPORTED_NAMES.has(name)
 57  );
 58}
 59
 60/** Whether every common `.env` reader loads a line with this valid name. */
 61export function isPortableEnvFileName(name: string): boolean {
 13062  return PORTABLE_ENV_FILE_NAME.test(name);
 63}
 64
 65/**
 66 * Builds the rejection message, so every caller reports the same wording. The
 67 * name is quoted rather than interpolated: it is untrusted input, and quoting
 68 * keeps a name carrying line terminators from breaking the message across
 69 * lines. Only the name is echoed, never the mapped value.
 70 */
 71export function invalidEnvironmentVariableNameMessage(name: string): string {
 4572  if (typeof name !== 'string' || name.trim() === '') {
 873    return 'Environment variable name cannot be empty';
 74  }
 75
 3776  if (UNSUPPORTED_NAMES.has(name)) {
 277    return (
 78      `Unsupported environment variable name ${quoteName(name)}: ` +
 79      'a .env parser cannot read this name back, so it would be written ' +
 80      'but never resolved'
 81    );
 82  }
 83
 3584  return (
 85    `Invalid environment variable name ${quoteName(name)}: names may only ` +
 86    'contain letters, digits, underscore, dot, hyphen and slash, so that ' +
 87    'they can be read back from a .env file'
 88  );
 89}
 90
 91/**
 92 * Builds the warning for names a `.env` file holds but not every reader
 93 * loads. Names only, quoted for the same reason as the rejection message.
 94 */
 95export function nonPortableEnvFileNamesMessage(names: string[]): string {
 396  const quoted = names.map(quoteName).join(', ');
 397  return (
 98    `${quoted} contain "/": envilder writes and reads them back, but dotenv ` +
 99    'for Node.js skips those lines and a POSIX shell cannot source them. ' +
 100    'If your app reads the .env file with one of those, use "envilder exec" ' +
 101    'or rename them with "__" (Database__Password)'
 102  );
 103}
 104
 105/**
 106 * Quotes an untrusted name for a message, so a name carrying a line
 107 * terminator cannot break the message across lines.
 108 */
 109export function quoteName(name: string): string {
 110  // JSON.stringify escapes CR, LF and the other control characters, but leaves
 111  // U+2028/U+2029 literal even though they terminate a line in JavaScript.
 41112  return JSON.stringify(name)
 113    .replace(/\u{2028}/gu, '\\u2028')
 114    .replace(/\u{2029}/gu, '\\u2029');
 115}