Skip to content

Commit 0b607b1

Browse files
IlyasShabirichardlau
authored andcommitted
src: support multi-line values for .env file
PR-URL: #51289 Reviewed-By: Yagiz Nizipli <yagiz.nizipli@sentry.io> Reviewed-By: Zeyu "Alex" Yang <himself65@outlook.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> Reviewed-By: Franziska Hinkelmann <franziska.hinkelmann@gmail.com>
1 parent 50ec690 commit 0b607b1

File tree

6 files changed

+114
-56
lines changed

6 files changed

+114
-56
lines changed

doc/api/cli.md

+18
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,10 @@ of `--enable-source-maps`.
655655
656656
<!-- YAML
657657
added: v20.6.0
658+
changes:
659+
- version: REPLACEME
660+
pr-url: https://github.com/nodejs/node/pull/51289
661+
description: Add support to multi-line values.
658662
-->
659663

660664
Loads environment variables from a file relative to the current directory,
@@ -691,6 +695,20 @@ They are omitted from the values.
691695
USERNAME="nodejs" # will result in `nodejs` as the value.
692696
```
693697

698+
Multi-line values are supported:
699+
700+
```text
701+
MULTI_LINE="THIS IS
702+
A MULTILINE"
703+
# will result in `THIS IS\nA MULTILINE` as the value.
704+
```
705+
706+
Export keyword before a key is ignored:
707+
708+
```text
709+
export USERNAME="nodejs" # will result in `nodejs` as the value.
710+
```
711+
694712
### `-e`, `--eval "script"`
695713

696714
<!-- YAML

src/node_dotenv.cc

+44-53
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#include "node_dotenv.h"
2+
#include <regex> // NOLINT(build/c++11)
3+
#include <unordered_set>
24
#include "env-inl.h"
35
#include "node_file.h"
46
#include "uv.h"
@@ -10,6 +12,15 @@ using v8::NewStringType;
1012
using v8::Object;
1113
using v8::String;
1214

15+
/**
16+
* The inspiration for this implementation comes from the original dotenv code,
17+
* available at https://github.com/motdotla/dotenv
18+
*/
19+
const std::regex LINE(
20+
"\\s*(?:export\\s+)?([\\w.-]+)(?:\\s*=\\s*?|:\\s+?)(\\s*'(?:\\\\'|[^']"
21+
")*'|\\s*\"(?:\\\\\"|[^\"])*\"|\\s*`(?:\\\\`|[^`])*`|[^#\r\n]+)?\\s*(?"
22+
":#.*)?"); // NOLINT(whitespace/line_length)
23+
1324
std::vector<std::string> Dotenv::GetPathFromArgs(
1425
const std::vector<std::string>& args) {
1526
const auto find_match = [](const std::string& arg) {
@@ -91,11 +102,34 @@ Local<Object> Dotenv::ToObject(Environment* env) {
91102
}
92103

93104
void Dotenv::ParseContent(const std::string_view content) {
94-
using std::string_view_literals::operator""sv;
95-
auto lines = SplitString(content, "\n"sv);
105+
std::string lines = std::string(content);
106+
lines = std::regex_replace(lines, std::regex("\r\n?"), "\n");
107+
108+
std::smatch match;
109+
while (std::regex_search(lines, match, LINE)) {
110+
const std::string key = match[1].str();
111+
112+
// Default undefined or null to an empty string
113+
std::string value = match[2].str();
114+
115+
// Remove leading whitespaces
116+
value.erase(0, value.find_first_not_of(" \t"));
117+
118+
// Remove trailing whitespaces
119+
value.erase(value.find_last_not_of(" \t") + 1);
120+
121+
const char maybeQuote = value.front();
122+
123+
if (maybeQuote == '"') {
124+
value = std::regex_replace(value, std::regex("\\\\n"), "\n");
125+
value = std::regex_replace(value, std::regex("\\\\r"), "\r");
126+
}
127+
128+
// Remove surrounding quotes
129+
value = trim_quotes(value);
96130

97-
for (const auto& line : lines) {
98-
ParseLine(line);
131+
store_.insert_or_assign(std::string(key), value);
132+
lines = match.suffix();
99133
}
100134
}
101135

@@ -145,56 +179,13 @@ void Dotenv::AssignNodeOptionsIfAvailable(std::string* node_options) {
145179
}
146180
}
147181

148-
void Dotenv::ParseLine(const std::string_view line) {
149-
auto equal_index = line.find('=');
150-
151-
if (equal_index == std::string_view::npos) {
152-
return;
153-
}
154-
155-
auto key = line.substr(0, equal_index);
156-
157-
// Remove leading and trailing space characters from key.
158-
while (!key.empty() && std::isspace(key.front())) key.remove_prefix(1);
159-
while (!key.empty() && std::isspace(key.back())) key.remove_suffix(1);
160-
161-
// Omit lines with comments
162-
if (key.front() == '#' || key.empty()) {
163-
return;
164-
}
165-
166-
auto value = std::string(line.substr(equal_index + 1));
167-
168-
// Might start and end with `"' characters.
169-
auto quotation_index = value.find_first_of("`\"'");
170-
171-
if (quotation_index == 0) {
172-
auto quote_character = value[quotation_index];
173-
value.erase(0, 1);
174-
175-
auto end_quotation_index = value.find(quote_character);
176-
177-
// We couldn't find the closing quotation character. Terminate.
178-
if (end_quotation_index == std::string::npos) {
179-
return;
180-
}
181-
182-
value.erase(end_quotation_index);
183-
} else {
184-
auto hash_index = value.find('#');
185-
186-
// Remove any inline comments
187-
if (hash_index != std::string::npos) {
188-
value.erase(hash_index);
189-
}
190-
191-
// Remove any leading/trailing spaces from unquoted values.
192-
while (!value.empty() && std::isspace(value.front())) value.erase(0, 1);
193-
while (!value.empty() && std::isspace(value.back()))
194-
value.erase(value.size() - 1);
182+
std::string_view Dotenv::trim_quotes(std::string_view str) {
183+
static const std::unordered_set<char> quotes = {'"', '\'', '`'};
184+
if (str.size() >= 2 && quotes.count(str.front()) &&
185+
quotes.count(str.back())) {
186+
str = str.substr(1, str.size() - 2);
195187
}
196-
197-
store_.insert_or_assign(std::string(key), value);
188+
return str;
198189
}
199190

200191
} // namespace node

src/node_dotenv.h

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ class Dotenv {
3131
const std::vector<std::string>& args);
3232

3333
private:
34-
void ParseLine(const std::string_view line);
3534
std::map<std::string, std::string> store_;
35+
std::string_view trim_quotes(std::string_view str);
3636
};
3737

3838
} // namespace node

test/fixtures/dotenv/valid.env

+25
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ BACKTICKS_SPACED=` backticks `
2020
DOUBLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" work inside backticks`
2121
SINGLE_QUOTES_INSIDE_BACKTICKS=`single 'quotes' work inside backticks`
2222
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=`double "quotes" and single 'quotes' work inside backticks`
23+
EXPAND_NEWLINES="expand\nnew\nlines"
24+
DONT_EXPAND_UNQUOTED=dontexpand\nnewlines
25+
DONT_EXPAND_SQUOTED='dontexpand\nnewlines'
2326
# COMMENTS=work
2427
INLINE_COMMENTS=inline comments # work #very #well
2528
INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
@@ -34,3 +37,25 @@ TRIM_SPACE_FROM_UNQUOTED= some spaced out string
3437
EMAIL=therealnerdybeast@example.tld
3538
SPACED_KEY = parsed
3639
EDGE_CASE_INLINE_COMMENTS="VALUE1" # or "VALUE2" or "VALUE3"
40+
41+
MULTI_DOUBLE_QUOTED="THIS
42+
IS
43+
A
44+
MULTILINE
45+
STRING"
46+
47+
MULTI_SINGLE_QUOTED='THIS
48+
IS
49+
A
50+
MULTILINE
51+
STRING'
52+
53+
MULTI_BACKTICKED=`THIS
54+
IS
55+
A
56+
"MULTILINE'S"
57+
STRING`
58+
MULTI_NOT_VALID_QUOTE="
59+
MULTI_NOT_VALID=THIS
60+
IS NOT MULTILINE
61+
export EXAMPLE = ignore export

test/parallel/test-dotenv.js

+14
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ assert.strictEqual(process.env.INLINE_COMMENTS_DOUBLE_QUOTES, 'inline comments o
5252
assert.strictEqual(process.env.INLINE_COMMENTS_BACKTICKS, 'inline comments outside of #backticks');
5353
// Treats # character as start of comment
5454
assert.strictEqual(process.env.INLINE_COMMENTS_SPACE, 'inline comments start with a');
55+
// ignore comment
56+
assert.strictEqual(process.env.COMMENTS, undefined);
5557
// Respects equals signs in values
5658
assert.strictEqual(process.env.EQUAL_SIGNS, 'equals==');
5759
// Retains inner quotes
@@ -70,3 +72,15 @@ assert.strictEqual(process.env.EMAIL, 'therealnerdybeast@example.tld');
7072
assert.strictEqual(process.env.SPACED_KEY, 'parsed');
7173
// Parse inline comments correctly when multiple quotes
7274
assert.strictEqual(process.env.EDGE_CASE_INLINE_COMMENTS, 'VALUE1');
75+
// Test multi-line values with line breaks
76+
assert.strictEqual(process.env.MULTI_DOUBLE_QUOTED, 'THIS\nIS\nA\nMULTILINE\nSTRING');
77+
assert.strictEqual(process.env.MULTI_SINGLE_QUOTED, 'THIS\nIS\nA\nMULTILINE\nSTRING');
78+
assert.strictEqual(process.env.MULTI_BACKTICKED, 'THIS\nIS\nA\n"MULTILINE\'S"\nSTRING');
79+
assert.strictEqual(process.env.MULTI_NOT_VALID_QUOTE, '"');
80+
assert.strictEqual(process.env.MULTI_NOT_VALID, 'THIS');
81+
// Test that \n is expanded to a newline in double-quoted string
82+
assert.strictEqual(process.env.EXPAND_NEWLINES, 'expand\nnew\nlines');
83+
assert.strictEqual(process.env.DONT_EXPAND_UNQUOTED, 'dontexpand\\nnewlines');
84+
assert.strictEqual(process.env.DONT_EXPAND_SQUOTED, 'dontexpand\\nnewlines');
85+
// Ignore export before key
86+
assert.strictEqual(process.env.EXAMPLE, 'ignore export');

test/parallel/util-parse-env.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,33 @@ const fs = require('node:fs');
1717
BACKTICKS_INSIDE_SINGLE: '`backticks` work inside single quotes',
1818
BACKTICKS_SPACED: ' backticks ',
1919
BASIC: 'basic',
20-
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS: 'double "quotes" and single \'quotes\' work inside backticks',
20+
DONT_EXPAND_SQUOTED: 'dontexpand\\nnewlines',
21+
DONT_EXPAND_UNQUOTED: 'dontexpand\\nnewlines',
22+
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS: "double \"quotes\" and single 'quotes' work inside backticks",
2123
DOUBLE_QUOTES: 'double_quotes',
2224
DOUBLE_QUOTES_INSIDE_BACKTICKS: 'double "quotes" work inside backticks',
2325
DOUBLE_QUOTES_INSIDE_SINGLE: 'double "quotes" work inside single quotes',
2426
DOUBLE_QUOTES_SPACED: ' double quotes ',
2527
DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET: '{ port: $MONGOLAB_PORT}',
28+
EDGE_CASE_INLINE_COMMENTS: 'VALUE1',
2629
EMAIL: 'therealnerdybeast@example.tld',
2730
EMPTY: '',
2831
EMPTY_BACKTICKS: '',
2932
EMPTY_DOUBLE_QUOTES: '',
3033
EMPTY_SINGLE_QUOTES: '',
3134
EQUAL_SIGNS: 'equals==',
35+
EXAMPLE: 'ignore export',
36+
EXPAND_NEWLINES: 'expand\nnew\nlines',
3237
INLINE_COMMENTS: 'inline comments',
3338
INLINE_COMMENTS_BACKTICKS: 'inline comments outside of #backticks',
3439
INLINE_COMMENTS_DOUBLE_QUOTES: 'inline comments outside of #doublequotes',
3540
INLINE_COMMENTS_SINGLE_QUOTES: 'inline comments outside of #singlequotes',
3641
INLINE_COMMENTS_SPACE: 'inline comments start with a',
42+
MULTI_BACKTICKED: 'THIS\nIS\nA\n"MULTILINE\'S"\nSTRING',
43+
MULTI_DOUBLE_QUOTED: 'THIS\nIS\nA\nMULTILINE\nSTRING',
44+
MULTI_NOT_VALID: 'THIS',
45+
MULTI_NOT_VALID_QUOTE: '"',
46+
MULTI_SINGLE_QUOTED: 'THIS\nIS\nA\nMULTILINE\nSTRING',
3747
RETAIN_INNER_QUOTES: '{"foo": "bar"}',
3848
RETAIN_INNER_QUOTES_AS_BACKTICKS: '{"foo": "bar\'s"}',
3949
RETAIN_INNER_QUOTES_AS_STRING: '{"foo": "bar"}',
@@ -42,7 +52,7 @@ const fs = require('node:fs');
4252
SINGLE_QUOTES_INSIDE_DOUBLE: "single 'quotes' work inside double quotes",
4353
SINGLE_QUOTES_SPACED: ' single quotes ',
4454
SPACED_KEY: 'parsed',
45-
TRIM_SPACE_FROM_UNQUOTED: 'some spaced out string'
55+
TRIM_SPACE_FROM_UNQUOTED: 'some spaced out string',
4656
});
4757
}
4858

0 commit comments

Comments
 (0)