From 29f2478d32fbe69e6dfedf083a336bf42ab90347 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 17 Apr 2026 19:24:50 -0300 Subject: [PATCH] src: preserve dotenv key insertion order Keep parseEnv output keys in the same order they first appear in the input Fixes: https://github.com/nodejs/node/issues/62736 Signed-off-by: Jonathan Lopes --- src/node_dotenv.cc | 42 +++++++++++++++++++--------- src/node_dotenv.h | 1 + test/parallel/test-util-parse-env.js | 24 ++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/node_dotenv.cc b/src/node_dotenv.cc index e1940904d1c039..01c743b9ff8f38 100644 --- a/src/node_dotenv.cc +++ b/src/node_dotenv.cc @@ -89,19 +89,24 @@ Maybe Dotenv::SetEnvironment(node::Environment* env) { MaybeLocal Dotenv::ToObject(Environment* env) const { EscapableHandleScope scope(env->isolate()); - LocalVector names(env->isolate(), store_.size()); - LocalVector values(env->isolate(), store_.size()); + LocalVector names(env->isolate(), keys_order_.size()); + LocalVector values(env->isolate(), keys_order_.size()); auto context = env->context(); Local tmp; int n = 0; - for (const auto& entry : store_) { - if (!ToV8Value(context, entry.first).ToLocal(&tmp)) { + for (const auto& key : keys_order_) { + auto entry = store_.find(key); + if (entry == store_.end()) { + continue; + } + + if (!ToV8Value(context, entry->first).ToLocal(&tmp)) { return MaybeLocal(); } names[n] = tmp.As(); - if (!ToV8Value(context, entry.second).ToLocal(&tmp)) { + if (!ToV8Value(context, entry->second).ToLocal(&tmp)) { return MaybeLocal(); } values[n++] = tmp; @@ -138,6 +143,17 @@ std::string_view trim_spaces(std::string_view input) { void Dotenv::ParseContent(const std::string_view input) { std::string lines(input); + const auto set_entry = [this](std::string_view entry_key, + std::string entry_value) { + auto [it, inserted] = + store_.insert_or_assign(std::string(entry_key), + std::move(entry_value)); + + if (inserted) { + keys_order_.push_back(it->first); + } + }; + // Handle windows newlines "\r\n": remove "\r" and keep only "\n" lines.erase(std::remove(lines.begin(), lines.end(), '\r'), lines.end()); @@ -187,7 +203,7 @@ void Dotenv::ParseContent(const std::string_view input) { // If the value is not present (e.g. KEY=) set it to an empty string if (content.empty() || content.front() == '\n') { - store_.insert_or_assign(std::string(key), ""); + set_entry(key, ""); continue; } @@ -212,7 +228,7 @@ void Dotenv::ParseContent(const std::string_view input) { if (content.empty()) { // In case the last line is a single key without value // Example: KEY= (without a newline at the EOF) - store_.insert_or_assign(std::string(key), ""); + set_entry(key, ""); break; } @@ -232,7 +248,7 @@ void Dotenv::ParseContent(const std::string_view input) { pos += 1; } - store_.insert_or_assign(std::string(key), multi_line_value); + set_entry(key, std::move(multi_line_value)); auto newline = content.find('\n', closing_quote + 1); if (newline != std::string_view::npos) { content.remove_prefix(newline + 1); @@ -259,18 +275,18 @@ void Dotenv::ParseContent(const std::string_view input) { auto newline = content.find('\n'); if (newline != std::string_view::npos) { value = content.substr(0, newline); - store_.insert_or_assign(std::string(key), value); + set_entry(key, std::string(value)); content.remove_prefix(newline + 1); } else { // No newline - take rest of content value = content; - store_.insert_or_assign(std::string(key), value); + set_entry(key, std::string(value)); break; } } else { // Found closing quote - take content between quotes value = content.substr(1, closing_quote - 1); - store_.insert_or_assign(std::string(key), value); + set_entry(key, std::string(value)); auto newline = content.find('\n', closing_quote + 1); if (newline != std::string_view::npos) { // Use +1 to discard the '\n' itself => next line @@ -296,7 +312,7 @@ void Dotenv::ParseContent(const std::string_view input) { value = value.substr(0, hash_character); } value = trim_spaces(value); - store_.insert_or_assign(std::string(key), std::string(value)); + set_entry(key, std::string(value)); content.remove_prefix(newline + 1); } else { // Last line without newline @@ -305,7 +321,7 @@ void Dotenv::ParseContent(const std::string_view input) { if (hash_char != std::string_view::npos) { value = content.substr(0, hash_char); } - store_.insert_or_assign(std::string(key), trim_spaces(value)); + set_entry(key, std::string(trim_spaces(value))); content = {}; } } diff --git a/src/node_dotenv.h b/src/node_dotenv.h index 689c763907c26a..7ee073b86c7be1 100644 --- a/src/node_dotenv.h +++ b/src/node_dotenv.h @@ -36,6 +36,7 @@ class Dotenv { private: std::map store_; + std::vector keys_order_; }; } // namespace node diff --git a/test/parallel/test-util-parse-env.js b/test/parallel/test-util-parse-env.js index e932a31f683e28..8ae826cf4289a9 100644 --- a/test/parallel/test-util-parse-env.js +++ b/test/parallel/test-util-parse-env.js @@ -74,3 +74,27 @@ assert.throws(() => { }, { code: 'ERR_INVALID_ARG_TYPE', }); + +// Test parse envs keep the order of keys as they appear in the input string +{ + const input = ` +PASSWORD="s1mpl3" +DB_PASS=$PASSWORD + `.trim(); + + const parsed = util.parseEnv(input); + const keys = Object.keys(parsed); + + assert.deepStrictEqual(keys, ['PASSWORD', 'DB_PASS']); +} + +// Test that when a key appears multiple times, the last value is used, +// but the order of keys is determined by the first occurrence +{ + const input = 'A=1\nB=2\nA=3'; + const parsed = util.parseEnv(input); + const keys = Object.keys(parsed); + + assert.deepStrictEqual(keys, ['A', 'B']); + assert.deepStrictEqual(parsed, { A: '3', B: '2', __proto__: null }); +}