From 512c56a806f9d6f724fb204bc1fd17a0d65f08db Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Tue, 26 Dec 2023 13:44:48 +0100 Subject: [PATCH 1/3] src: support multi-line values for .env file --- src/node_dotenv.cc | 99 ++++++++++++++++------------------ src/node_dotenv.h | 2 +- test/fixtures/dotenv/valid.env | 21 ++++++++ test/parallel/test-dotenv.js | 6 +++ 4 files changed, 73 insertions(+), 55 deletions(-) diff --git a/src/node_dotenv.cc b/src/node_dotenv.cc index 718e5407040505..d735511f141ab7 100644 --- a/src/node_dotenv.cc +++ b/src/node_dotenv.cc @@ -1,4 +1,6 @@ #include "node_dotenv.h" +#include // NOLINT(build/c++11) +#include #include "env-inl.h" #include "node_file.h" #include "uv.h" @@ -10,6 +12,15 @@ using v8::NewStringType; using v8::Object; using v8::String; +/** + * The inspiration for this implementation comes from the original dotenv code, + * available at https://github.com/motdotla/dotenv + */ +const std::regex LINE( + "\\s*(?:export\\s+)?([\\w.-]+)(?:\\s*=\\s*?|:\\s+?)(\\s*'(?:\\\\'|[^']" + ")*'|\\s*\"(?:\\\\\"|[^\"])*\"|\\s*`(?:\\\\`|[^`])*`|[^#\r\n]+)?\\s*(?" + ":#.*)?"); // NOLINT(whitespace/line_length) + std::vector Dotenv::GetPathFromArgs( const std::vector& args) { const auto find_match = [](const std::string& arg) { @@ -94,9 +105,33 @@ void Dotenv::ParseContent(const std::string_view content) { using std::string_view_literals::operator""sv; auto lines = SplitString(content, "\n"sv); - for (const auto& line : lines) { - ParseLine(line); + std::smatch match; + while (std::regex_search(lines, match, LINE)) { + const std::string key = match[1].str(); + + // Default undefined or null to an empty string + std::string value = match[2].str(); + + // Remove leading whitespaces + value.erase(0, value.find_first_not_of(" \t")); + + // Remove trailing whitespaces + value.erase(value.find_last_not_of(" \t") + 1); + + const char maybeQuote = value.front(); + + if (maybeQuote == '"') { + value = std::regex_replace(value, std::regex("\\\\n"), "\n"); + value = std::regex_replace(value, std::regex("\\\\r"), "\r"); + } + + // Remove surrounding quotes + value = trim_quotes(value); + + store_.insert_or_assign(std::string(key), value); + lines = match.suffix(); } + } Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) { @@ -116,7 +151,7 @@ Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) { uv_fs_req_cleanup(&close_req); }); - std::string result{}; + std::string lines{}; char buffer[8192]; uv_buf_t buf = uv_buf_init(buffer, sizeof(buffer)); @@ -130,11 +165,11 @@ Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) { if (r <= 0) { break; } - result.append(buf.base, r); + lines.append(buf.base, r); } ParseContent(result); - return ParseResult::Valid; + return true; } void Dotenv::AssignNodeOptionsIfAvailable(std::string* node_options) { @@ -145,56 +180,12 @@ void Dotenv::AssignNodeOptionsIfAvailable(std::string* node_options) { } } -void Dotenv::ParseLine(const std::string_view line) { - auto equal_index = line.find('='); - - if (equal_index == std::string_view::npos) { - return; +std::string Dotenv::trim_quotes(std::string str) { + static const std::unordered_set quotes = {'"', '\'', '`'}; + if (str.size() >= 2 && quotes.count(str[0]) && quotes.count(str.back())) { + str = str.substr(1, str.size() - 2); } - - auto key = line.substr(0, equal_index); - - // Remove leading and trailing space characters from key. - while (!key.empty() && std::isspace(key.front())) key.remove_prefix(1); - while (!key.empty() && std::isspace(key.back())) key.remove_suffix(1); - - // Omit lines with comments - if (key.front() == '#' || key.empty()) { - return; - } - - auto value = std::string(line.substr(equal_index + 1)); - - // Might start and end with `"' characters. - auto quotation_index = value.find_first_of("`\"'"); - - if (quotation_index == 0) { - auto quote_character = value[quotation_index]; - value.erase(0, 1); - - auto end_quotation_index = value.find(quote_character); - - // We couldn't find the closing quotation character. Terminate. - if (end_quotation_index == std::string::npos) { - return; - } - - value.erase(end_quotation_index); - } else { - auto hash_index = value.find('#'); - - // Remove any inline comments - if (hash_index != std::string::npos) { - value.erase(hash_index); - } - - // Remove any leading/trailing spaces from unquoted values. - while (!value.empty() && std::isspace(value.front())) value.erase(0, 1); - while (!value.empty() && std::isspace(value.back())) - value.erase(value.size() - 1); - } - - store_.insert_or_assign(std::string(key), value); + return str; } } // namespace node diff --git a/src/node_dotenv.h b/src/node_dotenv.h index f2a9ce56f41570..cf0fe02b108e5a 100644 --- a/src/node_dotenv.h +++ b/src/node_dotenv.h @@ -31,8 +31,8 @@ class Dotenv { const std::vector& args); private: - void ParseLine(const std::string_view line); std::map store_; + std::string trim_quotes(std::string str); }; } // namespace node diff --git a/test/fixtures/dotenv/valid.env b/test/fixtures/dotenv/valid.env index 980d3621b0c4df..9776f6d42583e5 100644 --- a/test/fixtures/dotenv/valid.env +++ b/test/fixtures/dotenv/valid.env @@ -34,3 +34,24 @@ TRIM_SPACE_FROM_UNQUOTED= some spaced out string EMAIL=therealnerdybeast@example.tld SPACED_KEY = parsed EDGE_CASE_INLINE_COMMENTS="VALUE1" # or "VALUE2" or "VALUE3" + +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' + +MULTI_BACKTICKED=`THIS +IS +A +"MULTILINE'S" +STRING` +MULTI_NOT_VALID_QUOTE=" +MULTI_NOT_VALID=THIS +IS NOT MULTILINE diff --git a/test/parallel/test-dotenv.js b/test/parallel/test-dotenv.js index efc5a164b39334..05108a025ab46c 100644 --- a/test/parallel/test-dotenv.js +++ b/test/parallel/test-dotenv.js @@ -70,3 +70,9 @@ assert.strictEqual(process.env.EMAIL, 'therealnerdybeast@example.tld'); assert.strictEqual(process.env.SPACED_KEY, 'parsed'); // Parse inline comments correctly when multiple quotes assert.strictEqual(process.env.EDGE_CASE_INLINE_COMMENTS, 'VALUE1'); +// Test multiple-line value +assert.strictEqual(process.env.MULTI_DOUBLE_QUOTED, 'THIS\nIS\nA\nMULTILINE\nSTRING'); +assert.strictEqual(process.env.MULTI_SINGLE_QUOTED, 'THIS\nIS\nA\nMULTILINE\nSTRING'); +assert.strictEqual(process.env.MULTI_BACKTICKED, 'THIS\nIS\nA\n"MULTILINE\'S"\nSTRING'); +assert.strictEqual(process.env.MULTI_NOT_VALID_QUOTE, '"'); +assert.strictEqual(process.env.MULTI_NOT_VALID, 'THIS'); From 4c11ddc7c8a42154d58eec4878e5105a9ddbfa2f Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Sun, 7 Jan 2024 18:57:50 +0100 Subject: [PATCH 2/3] src: ignore export before key --- test/fixtures/dotenv/valid.env | 1 + test/parallel/test-dotenv.js | 1 + 2 files changed, 2 insertions(+) diff --git a/test/fixtures/dotenv/valid.env b/test/fixtures/dotenv/valid.env index 9776f6d42583e5..9e040f46da93bd 100644 --- a/test/fixtures/dotenv/valid.env +++ b/test/fixtures/dotenv/valid.env @@ -55,3 +55,4 @@ STRING` MULTI_NOT_VALID_QUOTE=" MULTI_NOT_VALID=THIS IS NOT MULTILINE +export EXAMPLE = ignore export diff --git a/test/parallel/test-dotenv.js b/test/parallel/test-dotenv.js index 05108a025ab46c..d1cc5bd21e9a3f 100644 --- a/test/parallel/test-dotenv.js +++ b/test/parallel/test-dotenv.js @@ -76,3 +76,4 @@ assert.strictEqual(process.env.MULTI_SINGLE_QUOTED, 'THIS\nIS\nA\nMULTILINE\nSTR assert.strictEqual(process.env.MULTI_BACKTICKED, 'THIS\nIS\nA\n"MULTILINE\'S"\nSTRING'); assert.strictEqual(process.env.MULTI_NOT_VALID_QUOTE, '"'); assert.strictEqual(process.env.MULTI_NOT_VALID, 'THIS'); +assert.strictEqual(process.env.EXAMPLE, 'ignore export'); From ecdc5e7c3500ff5f857e63fbd159787ca5dc41ef Mon Sep 17 00:00:00 2001 From: Ilyas Shabi Date: Sun, 7 Jan 2024 19:23:50 +0100 Subject: [PATCH 3/3] src: expand \n to a newline in double quote string --- doc/api/cli.md | 18 ++++++++++++++++++ src/node_dotenv.cc | 16 ++++++++-------- src/node_dotenv.h | 2 +- test/fixtures/dotenv/valid.env | 3 +++ test/parallel/test-dotenv.js | 9 ++++++++- test/parallel/util-parse-env.js | 14 ++++++++++++-- 6 files changed, 50 insertions(+), 12 deletions(-) diff --git a/doc/api/cli.md b/doc/api/cli.md index 840585765e7fa6..5ba337276597b9 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -666,6 +666,10 @@ of `--enable-source-maps`. Loads environment variables from a file relative to the current directory, @@ -702,6 +706,20 @@ They are omitted from the values. USERNAME="nodejs" # will result in `nodejs` as the value. ``` +Multi-line values are supported: + +```text +MULTI_LINE="THIS IS +A MULTILINE" +# will result in `THIS IS\nA MULTILINE` as the value. +``` + +Export keyword before a key is ignored: + +```text +export USERNAME="nodejs" # will result in `nodejs` as the value. +``` + ### `-e`, `--eval "script"`