Repository: nodejs/node
Pull request: util: fix parseEnv incorrectly splitting multiple ‘=‘ in value

Previously, parseEnv would create multiple environment variables if a single line contained multiple ‘=‘ characters (e.g. A=B=C would become { A: ‘B=C’, B: ‘C’ }).
This commit ensures that only the first ‘=‘ is used as the key-value delimiter, and the rest of the line is treated as the value.

Fixes: https://github.com/nodejs/node/issues/57411

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: benchmark/fixtures/valid.env
patch: @@ -6,6 +6,8 @@ BASIC=basic
 
 # previous line intentionally left blank
 AFTER_LINE=after_line
+A="B=C"
+B=C=D
 EMPTY=
 EMPTY_SINGLE_QUOTES=''
 EMPTY_DOUBLE_QUOTES=""

filename: src/node_dotenv.cc
patch: @@ -145,7 +145,16 @@ void Dotenv::ParseContent(const std::string_view input) {
     // If there is no equal character, then ignore everything
     auto equal = content.find('=');
     if (equal == std::string_view::npos) {
-      break;
+      auto newline = content.find('\n');
+      if (newline != std::string_view::npos) {
+        // If we used `newline` only,
+        // the '\n' might remain and cause an empty-line parse
+        content.remove_prefix(newline + 1);
+      } else {
+        content = {};
+      }
+      // No valid data here, skip to next line
+      continue;
     }
 
     key = content.substr(0, equal);
@@ -195,7 +204,9 @@ void Dotenv::ParseContent(const std::string_view input) {
         store_.insert_or_assign(std::string(key), multi_line_value);
         auto newline = content.find('\n', closing_quote + 1);
         if (newline != std::string_view::npos) {
-          content.remove_prefix(newline);
+          content.remove_prefix(newline + 1);
+        } else {
+          content = {};
         }
         continue;
       }
@@ -216,7 +227,7 @@ void Dotenv::ParseContent(const std::string_view input) {
         if (newline != std::string_view::npos) {
           value = content.substr(0, newline);
           store_.insert_or_assign(std::string(key), value);
-          content.remove_prefix(newline);
+          content.remove_prefix(newline + 1);
         }
       } else {
         // Example: KEY="value"
@@ -226,8 +237,13 @@ void Dotenv::ParseContent(const std::string_view input) {
         // since there could be newline characters inside the value.
         auto newline = content.find('\n', closing_quote + 1);
         if (newline != std::string_view::npos) {
-          content.remove_prefix(newline);
+          // Use +1 to discard the '\n' itself => next line
+          content.remove_prefix(newline + 1);
+        } else {
+          content = {};
         }
+        // No valid data here, skip to next line
+        continue;
       }
     } else {
       // Regular key value pair.
@@ -243,15 +259,21 @@ void Dotenv::ParseContent(const std::string_view input) {
         if (hash_character != std::string_view::npos) {
           value = content.substr(0, hash_character);
         }
-        content.remove_prefix(newline);
+        store_.insert_or_assign(std::string(key), trim_spaces(value));
+        content.remove_prefix(newline + 1);
       } else {
         // In case the last line is a single key/value pair
         // Example: KEY=VALUE (without a newline at the EOF)
-        value = content.substr(0);
+        value = content;
+        auto hash_char = value.find('#');
+        if (hash_char != std::string_view::npos) {
+          value = content.substr(0, hash_char);
+        }
+        store_.insert_or_assign(std::string(key), trim_spaces(value));
+        content = {};
       }
 
-      value = trim_spaces(value);
-      store_.insert_or_assign(std::string(key), value);
+      store_.insert_or_assign(std::string(key), trim_spaces(value));
     }
   }
 }

filename: test/fixtures/dotenv/valid.env
patch: @@ -6,6 +6,8 @@ BASIC=basic
 
 # previous line intentionally left blank
 AFTER_LINE=after_line
+A="B=C"
+B=C=D
 EMPTY=
 EMPTY_SINGLE_QUOTES=''
 EMPTY_DOUBLE_QUOTES=""

filename: test/parallel/test-dotenv.js
patch: @@ -82,3 +82,5 @@ assert.strictEqual(process.env.DONT_EXPAND_SQUOTED, 'dontexpand\\nnewlines');
 assert.strictEqual(process.env.EXPORT_EXAMPLE, 'ignore export');
 // Ignore spaces before double quotes to avoid quoted strings as value
 assert.strictEqual(process.env.SPACE_BEFORE_DOUBLE_QUOTES, 'space before double quotes');
+assert.strictEqual(process.env.A, 'B=C');
+assert.strictEqual(process.env.B, 'C=D');

filename: test/parallel/test-util-parse-env.js
patch: @@ -11,6 +11,8 @@ const fs = require('node:fs');
   const validContent = fs.readFileSync(validEnvFilePath, 'utf8');
 
   assert.deepStrictEqual(util.parseEnv(validContent), {
+    A: 'B=C',
+    B: 'C=D',
     AFTER_LINE: 'after_line',
     BACKTICKS: 'backticks',
     BACKTICKS_INSIDE_DOUBLE: '`backticks` work inside double quotes',