Skip to content

Commit

Permalink
src: support multi-line values for .env file
Browse files Browse the repository at this point in the history
PR-URL: #51289
Reviewed-By: Yagiz Nizipli <[email protected]>
Reviewed-By: Zeyu "Alex" Yang <[email protected]>
Reviewed-By: Rafael Gonzaga <[email protected]>
Reviewed-By: Franziska Hinkelmann <[email protected]>
  • Loading branch information
IlyasShabi authored and targos committed Feb 15, 2024
1 parent 3e579ab commit c7912c3
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 56 deletions.
18 changes: 18 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,10 @@ of `--enable-source-maps`.
<!-- YAML
added: v20.6.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/51289
description: Add support to multi-line values.
-->

Loads environment variables from a file relative to the current directory,
Expand Down Expand Up @@ -700,6 +704,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"`

<!-- YAML
Expand Down
97 changes: 44 additions & 53 deletions src/node_dotenv.cc
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include "node_dotenv.h"
#include <regex> // NOLINT(build/c++11)
#include <unordered_set>
#include "env-inl.h"
#include "node_file.h"
#include "uv.h"
Expand All @@ -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<std::string> Dotenv::GetPathFromArgs(
const std::vector<std::string>& args) {
const auto find_match = [](const std::string& arg) {
Expand Down Expand Up @@ -91,11 +102,34 @@ Local<Object> Dotenv::ToObject(Environment* env) {
}

void Dotenv::ParseContent(const std::string_view content) {
using std::string_view_literals::operator""sv;
auto lines = SplitString(content, "\n"sv);
std::string lines = std::string(content);
lines = std::regex_replace(lines, std::regex("\r\n?"), "\n");

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);

for (const auto& line : lines) {
ParseLine(line);
store_.insert_or_assign(std::string(key), value);
lines = match.suffix();
}
}

Expand Down Expand Up @@ -145,56 +179,13 @@ 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;
}

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);
std::string_view Dotenv::trim_quotes(std::string_view str) {
static const std::unordered_set<char> quotes = {'"', '\'', '`'};
if (str.size() >= 2 && quotes.count(str.front()) &&
quotes.count(str.back())) {
str = str.substr(1, str.size() - 2);
}

store_.insert_or_assign(std::string(key), value);
return str;
}

} // namespace node
2 changes: 1 addition & 1 deletion src/node_dotenv.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ class Dotenv {
const std::vector<std::string>& args);

private:
void ParseLine(const std::string_view line);
std::map<std::string, std::string> store_;
std::string_view trim_quotes(std::string_view str);
};

} // namespace node
Expand Down
25 changes: 25 additions & 0 deletions test/fixtures/dotenv/valid.env
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ BACKTICKS_SPACED=` backticks `
DOUBLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" work inside backticks`
SINGLE_QUOTES_INSIDE_BACKTICKS=`single 'quotes' work inside backticks`
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" and single 'quotes' work inside backticks`
EXPAND_NEWLINES="expand\nnew\nlines"
DONT_EXPAND_UNQUOTED=dontexpand\nnewlines
DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
# COMMENTS=work
INLINE_COMMENTS=inline comments # work #very #well
INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
Expand All @@ -34,3 +37,25 @@ TRIM_SPACE_FROM_UNQUOTED= some spaced out string
EMAIL=[email protected]
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
export EXAMPLE = ignore export
14 changes: 14 additions & 0 deletions test/parallel/test-dotenv.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ assert.strictEqual(process.env.INLINE_COMMENTS_DOUBLE_QUOTES, 'inline comments o
assert.strictEqual(process.env.INLINE_COMMENTS_BACKTICKS, 'inline comments outside of #backticks');
// Treats # character as start of comment
assert.strictEqual(process.env.INLINE_COMMENTS_SPACE, 'inline comments start with a');
// ignore comment
assert.strictEqual(process.env.COMMENTS, undefined);
// Respects equals signs in values
assert.strictEqual(process.env.EQUAL_SIGNS, 'equals==');
// Retains inner quotes
Expand All @@ -70,3 +72,15 @@ assert.strictEqual(process.env.EMAIL, '[email protected]');
assert.strictEqual(process.env.SPACED_KEY, 'parsed');
// Parse inline comments correctly when multiple quotes
assert.strictEqual(process.env.EDGE_CASE_INLINE_COMMENTS, 'VALUE1');
// Test multi-line values with line breaks
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');
// Test that \n is expanded to a newline in double-quoted string
assert.strictEqual(process.env.EXPAND_NEWLINES, 'expand\nnew\nlines');
assert.strictEqual(process.env.DONT_EXPAND_UNQUOTED, 'dontexpand\\nnewlines');
assert.strictEqual(process.env.DONT_EXPAND_SQUOTED, 'dontexpand\\nnewlines');
// Ignore export before key
assert.strictEqual(process.env.EXAMPLE, 'ignore export');
14 changes: 12 additions & 2 deletions test/parallel/util-parse-env.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,33 @@ const fs = require('node:fs');
BACKTICKS_INSIDE_SINGLE: '`backticks` work inside single quotes',
BACKTICKS_SPACED: ' backticks ',
BASIC: 'basic',
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS: 'double "quotes" and single \'quotes\' work inside backticks',
DONT_EXPAND_SQUOTED: 'dontexpand\\nnewlines',
DONT_EXPAND_UNQUOTED: 'dontexpand\\nnewlines',
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS: "double \"quotes\" and single 'quotes' work inside backticks",
DOUBLE_QUOTES: 'double_quotes',
DOUBLE_QUOTES_INSIDE_BACKTICKS: 'double "quotes" work inside backticks',
DOUBLE_QUOTES_INSIDE_SINGLE: 'double "quotes" work inside single quotes',
DOUBLE_QUOTES_SPACED: ' double quotes ',
DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET: '{ port: $MONGOLAB_PORT}',
EDGE_CASE_INLINE_COMMENTS: 'VALUE1',
EMAIL: '[email protected]',
EMPTY: '',
EMPTY_BACKTICKS: '',
EMPTY_DOUBLE_QUOTES: '',
EMPTY_SINGLE_QUOTES: '',
EQUAL_SIGNS: 'equals==',
EXAMPLE: 'ignore export',
EXPAND_NEWLINES: 'expand\nnew\nlines',
INLINE_COMMENTS: 'inline comments',
INLINE_COMMENTS_BACKTICKS: 'inline comments outside of #backticks',
INLINE_COMMENTS_DOUBLE_QUOTES: 'inline comments outside of #doublequotes',
INLINE_COMMENTS_SINGLE_QUOTES: 'inline comments outside of #singlequotes',
INLINE_COMMENTS_SPACE: 'inline comments start with a',
MULTI_BACKTICKED: 'THIS\nIS\nA\n"MULTILINE\'S"\nSTRING',
MULTI_DOUBLE_QUOTED: 'THIS\nIS\nA\nMULTILINE\nSTRING',
MULTI_NOT_VALID: 'THIS',
MULTI_NOT_VALID_QUOTE: '"',
MULTI_SINGLE_QUOTED: 'THIS\nIS\nA\nMULTILINE\nSTRING',
RETAIN_INNER_QUOTES: '{"foo": "bar"}',
RETAIN_INNER_QUOTES_AS_BACKTICKS: '{"foo": "bar\'s"}',
RETAIN_INNER_QUOTES_AS_STRING: '{"foo": "bar"}',
Expand All @@ -42,7 +52,7 @@ const fs = require('node:fs');
SINGLE_QUOTES_INSIDE_DOUBLE: "single 'quotes' work inside double quotes",
SINGLE_QUOTES_SPACED: ' single quotes ',
SPACED_KEY: 'parsed',
TRIM_SPACE_FROM_UNQUOTED: 'some spaced out string'
TRIM_SPACE_FROM_UNQUOTED: 'some spaced out string',
});
}

Expand Down

0 comments on commit c7912c3

Please sign in to comment.