Skip to content

Commit 27491e5

Browse files
IlyasShabimarco-ippolito
authored andcommitted
src: remove regex usage for env file parsing
PR-URL: #52406 Fixes: #52248 Reviewed-By: Yagiz Nizipli <[email protected]>
1 parent b05e639 commit 27491e5

File tree

8 files changed

+173
-47
lines changed

8 files changed

+173
-47
lines changed

src/node_dotenv.cc

Lines changed: 122 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#include "node_dotenv.h"
2-
#include <regex> // NOLINT(build/c++11)
32
#include <unordered_set>
43
#include "env-inl.h"
54
#include "node_file.h"
@@ -12,15 +11,6 @@ using v8::NewStringType;
1211
using v8::Object;
1312
using v8::String;
1413

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-
2414
std::vector<std::string> Dotenv::GetPathFromArgs(
2515
const std::vector<std::string>& args) {
2616
const auto find_match = [](const std::string& arg) {
@@ -101,35 +91,137 @@ Local<Object> Dotenv::ToObject(Environment* env) {
10191
return result;
10292
}
10393

104-
void Dotenv::ParseContent(const std::string_view content) {
105-
std::string lines = std::string(content);
106-
lines = std::regex_replace(lines, std::regex("\r\n?"), "\n");
94+
std::string_view trim_spaces(std::string_view input) {
95+
if (input.empty()) return "";
96+
if (input.front() == ' ') {
97+
input.remove_prefix(input.find_first_not_of(' '));
98+
}
99+
if (!input.empty() && input.back() == ' ') {
100+
input = input.substr(0, input.find_last_not_of(' ') + 1);
101+
}
102+
return input;
103+
}
104+
105+
void Dotenv::ParseContent(const std::string_view input) {
106+
std::string lines(input);
107+
108+
// Handle windows newlines "\r\n": remove "\r" and keep only "\n"
109+
lines.erase(std::remove(lines.begin(), lines.end(), '\r'), lines.end());
110+
111+
std::string_view content = lines;
112+
content = trim_spaces(content);
113+
114+
std::string_view key;
115+
std::string_view value;
116+
117+
while (!content.empty()) {
118+
// Skip empty lines and comments
119+
if (content.front() == '\n' || content.front() == '#') {
120+
auto newline = content.find('\n');
121+
if (newline != std::string_view::npos) {
122+
content.remove_prefix(newline + 1);
123+
continue;
124+
}
125+
}
126+
127+
// If there is no equal character, then ignore everything
128+
auto equal = content.find('=');
129+
if (equal == std::string_view::npos) {
130+
break;
131+
}
107132

108-
std::smatch match;
109-
while (std::regex_search(lines, match, LINE)) {
110-
const std::string key = match[1].str();
133+
key = content.substr(0, equal);
134+
content.remove_prefix(equal + 1);
135+
key = trim_spaces(key);
111136

112-
// Default undefined or null to an empty string
113-
std::string value = match[2].str();
137+
if (key.empty()) {
138+
break;
139+
}
114140

115-
// Remove leading whitespaces
116-
value.erase(0, value.find_first_not_of(" \t"));
141+
// Remove export prefix from key
142+
auto have_export = key.compare(0, 7, "export ") == 0;
143+
if (have_export) {
144+
key.remove_prefix(7);
145+
}
117146

118-
// Remove trailing whitespaces
119-
if (!value.empty()) {
120-
value.erase(value.find_last_not_of(" \t") + 1);
147+
// SAFETY: Content is guaranteed to have at least one character
148+
if (content.empty()) {
149+
// In case the last line is a single key without value
150+
// Example: KEY= (without a newline at the EOF)
151+
store_.insert_or_assign(std::string(key), "");
152+
break;
121153
}
122154

123-
if (!value.empty() && value.front() == '"') {
124-
value = std::regex_replace(value, std::regex("\\\\n"), "\n");
125-
value = std::regex_replace(value, std::regex("\\\\r"), "\r");
155+
// Expand new line if \n it's inside double quotes
156+
// Example: EXPAND_NEWLINES = 'expand\nnew\nlines'
157+
if (content.front() == '"') {
158+
auto closing_quote = content.find(content.front(), 1);
159+
if (closing_quote != std::string_view::npos) {
160+
value = content.substr(1, closing_quote - 1);
161+
std::string multi_line_value = std::string(value);
162+
163+
size_t pos = 0;
164+
while ((pos = multi_line_value.find("\\n", pos)) !=
165+
std::string_view::npos) {
166+
multi_line_value.replace(pos, 2, "\n");
167+
pos += 1;
168+
}
169+
170+
store_.insert_or_assign(std::string(key), multi_line_value);
171+
content.remove_prefix(content.find('\n', closing_quote + 1));
172+
continue;
173+
}
126174
}
127175

128-
// Remove surrounding quotes
129-
value = trim_quotes(value);
176+
// Check if the value is wrapped in quotes, single quotes or backticks
177+
if ((content.front() == '\'' || content.front() == '"' ||
178+
content.front() == '`')) {
179+
auto closing_quote = content.find(content.front(), 1);
180+
181+
// Check if the closing quote is not found
182+
// Example: KEY="value
183+
if (closing_quote == std::string_view::npos) {
184+
// Check if newline exist. If it does, take the entire line as the value
185+
// Example: KEY="value\nKEY2=value2
186+
// The value pair should be `"value`
187+
auto newline = content.find('\n');
188+
if (newline != std::string_view::npos) {
189+
value = content.substr(0, newline);
190+
store_.insert_or_assign(std::string(key), value);
191+
content.remove_prefix(newline);
192+
}
193+
} else {
194+
// Example: KEY="value"
195+
value = content.substr(1, closing_quote - 1);
196+
store_.insert_or_assign(std::string(key), value);
197+
// Select the first newline after the closing quotation mark
198+
// since there could be newline characters inside the value.
199+
content.remove_prefix(content.find('\n', closing_quote + 1));
200+
}
201+
} else {
202+
// Regular key value pair.
203+
// Example: `KEY=this is value`
204+
auto newline = content.find('\n');
205+
206+
if (newline != std::string_view::npos) {
207+
value = content.substr(0, newline);
208+
auto hash_character = value.find('#');
209+
// Check if there is a comment in the line
210+
// Example: KEY=value # comment
211+
// The value pair should be `value`
212+
if (hash_character != std::string_view::npos) {
213+
value = content.substr(0, hash_character);
214+
}
215+
content.remove_prefix(newline);
216+
} else {
217+
// In case the last line is a single key/value pair
218+
// Example: KEY=VALUE (without a newline at the EOF)
219+
value = content.substr(0);
220+
}
130221

131-
store_.insert_or_assign(std::string(key), value);
132-
lines = match.suffix();
222+
value = trim_spaces(value);
223+
store_.insert_or_assign(std::string(key), value);
224+
}
133225
}
134226
}
135227

@@ -179,13 +271,4 @@ void Dotenv::AssignNodeOptionsIfAvailable(std::string* node_options) {
179271
}
180272
}
181273

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);
187-
}
188-
return str;
189-
}
190-
191274
} // namespace node

src/node_dotenv.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ class Dotenv {
3232

3333
private:
3434
std::map<std::string, std::string> store_;
35-
std::string_view trim_quotes(std::string_view str);
3635
};
3736

3837
} // namespace node
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
BASIC=value
2+
EMPTY=

test/fixtures/dotenv/multiline.env

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
2+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u
3+
LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/
4+
bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/
5+
kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V
6+
u4QuUoobAgMBAAE=
7+
-----END PUBLIC KEY-----"

test/fixtures/dotenv/valid.env

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
BASIC=basic
22

3+
# COMMENTS=work
4+
#BASIC=basic2
5+
#BASIC=basic3
6+
37
# previous line intentionally left blank
48
AFTER_LINE=after_line
59
EMPTY=
@@ -55,7 +59,8 @@ IS
5559
A
5660
"MULTILINE'S"
5761
STRING`
62+
export EXPORT_EXAMPLE = ignore export
63+
5864
MULTI_NOT_VALID_QUOTE="
5965
MULTI_NOT_VALID=THIS
6066
IS NOT MULTILINE
61-
export EXAMPLE = ignore export

test/parallel/test-dotenv-edge-cases.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const common = require('../common');
44
const assert = require('node:assert');
55
const path = require('node:path');
66
const { describe, it } = require('node:test');
7+
const fixtures = require('../common/fixtures');
78

89
const validEnvFilePath = '../fixtures/dotenv/valid.env';
910
const nodeOptionsEnvFilePath = '../fixtures/dotenv/node-options.env';
@@ -64,4 +65,37 @@ describe('.env supports edge cases', () => {
6465
assert.strictEqual(child.stderr, '');
6566
assert.strictEqual(child.code, 0);
6667
});
68+
69+
it('should handle multiline quoted values', async () => {
70+
// Ref: https://github.com/nodejs/node/issues/52248
71+
const code = `
72+
process.loadEnvFile('./multiline.env');
73+
require('node:assert').ok(process.env.JWT_PUBLIC_KEY);
74+
`.trim();
75+
const child = await common.spawnPromisified(
76+
process.execPath,
77+
[ '--eval', code ],
78+
{ cwd: fixtures.path('dotenv') },
79+
);
80+
assert.strictEqual(child.stdout, '');
81+
assert.strictEqual(child.stderr, '');
82+
assert.strictEqual(child.code, 0);
83+
});
84+
85+
it('should handle empty value without a newline at the EOF', async () => {
86+
// Ref: https://github.com/nodejs/node/issues/52466
87+
const code = `
88+
process.loadEnvFile('./eof-without-value.env');
89+
require('assert').strictEqual(process.env.BASIC, 'value');
90+
require('assert').strictEqual(process.env.EMPTY, '');
91+
`.trim();
92+
const child = await common.spawnPromisified(
93+
process.execPath,
94+
[ '--eval', code ],
95+
{ cwd: fixtures.path('dotenv') },
96+
);
97+
assert.strictEqual(child.stdout, '');
98+
assert.strictEqual(child.stderr, '');
99+
assert.strictEqual(child.code, 0);
100+
});
67101
});

test/parallel/test-dotenv.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,6 @@ assert.strictEqual(process.env.COMMENTS, undefined);
5858
assert.strictEqual(process.env.EQUAL_SIGNS, 'equals==');
5959
// Retains inner quotes
6060
assert.strictEqual(process.env.RETAIN_INNER_QUOTES, '{"foo": "bar"}');
61-
// Respects equals signs in values
62-
assert.strictEqual(process.env.EQUAL_SIGNS, 'equals==');
63-
// Retains inner quotes
64-
assert.strictEqual(process.env.RETAIN_INNER_QUOTES, '{"foo": "bar"}');
6561
assert.strictEqual(process.env.RETAIN_INNER_QUOTES_AS_STRING, '{"foo": "bar"}');
6662
assert.strictEqual(process.env.RETAIN_INNER_QUOTES_AS_BACKTICKS, '{"foo": "bar\'s"}');
6763
// Retains spaces in string
@@ -83,4 +79,4 @@ assert.strictEqual(process.env.EXPAND_NEWLINES, 'expand\nnew\nlines');
8379
assert.strictEqual(process.env.DONT_EXPAND_UNQUOTED, 'dontexpand\\nnewlines');
8480
assert.strictEqual(process.env.DONT_EXPAND_SQUOTED, 'dontexpand\\nnewlines');
8581
// Ignore export before key
86-
assert.strictEqual(process.env.EXAMPLE, 'ignore export');
82+
assert.strictEqual(process.env.EXPORT_EXAMPLE, 'ignore export');

test/parallel/util-parse-env.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const fs = require('node:fs');
3232
EMPTY_DOUBLE_QUOTES: '',
3333
EMPTY_SINGLE_QUOTES: '',
3434
EQUAL_SIGNS: 'equals==',
35-
EXAMPLE: 'ignore export',
35+
EXPORT_EXAMPLE: 'ignore export',
3636
EXPAND_NEWLINES: 'expand\nnew\nlines',
3737
INLINE_COMMENTS: 'inline comments',
3838
INLINE_COMMENTS_BACKTICKS: 'inline comments outside of #backticks',

0 commit comments

Comments
 (0)