Skip to content

Commit 7253c44

Browse files
Copilotrchiodo
andauthored
Fix multiline environment variable parsing in .env files (#928)
* Initial plan * Fix multiline environment variable parsing in .env files Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> * Improve escape sequence handling in environment parser Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> * Update comment for regex 's' flag usage Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> * Fix linter and prettier formatting issues Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com> Co-authored-by: Rich Chiodo <rchiodo@users.noreply.github.com>
1 parent 96a1935 commit 7253c44

File tree

2 files changed

+177
-9
lines changed

2 files changed

+177
-9
lines changed

src/extension/common/variables/environment.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,80 @@ export function appendPaths(
9494
export function parseEnvFile(lines: string | Buffer, baseVars?: EnvironmentVariables): EnvironmentVariables {
9595
const globalVars = baseVars ? baseVars : {};
9696
const vars: EnvironmentVariables = {};
97-
lines
98-
.toString()
99-
.split('\n')
100-
.forEach((line, _idx) => {
101-
const [name, value] = parseEnvLine(line);
102-
if (name === '') {
103-
return;
97+
const content = lines.toString();
98+
99+
// State machine to handle multiline quoted values
100+
let currentLine = '';
101+
let inQuotes = false;
102+
let quoteChar = '';
103+
let afterEquals = false;
104+
105+
for (let i = 0; i < content.length; i++) {
106+
const char = content[i];
107+
108+
// Track if we've seen an '=' sign (indicating we're in the value part)
109+
if (char === '=' && !inQuotes) {
110+
afterEquals = true;
111+
currentLine += char;
112+
continue;
113+
}
114+
115+
// Handle quote characters - need to check for proper escaping
116+
if ((char === '"' || char === "'") && afterEquals) {
117+
// Count consecutive backslashes before this quote
118+
let numBackslashes = 0;
119+
let j = i - 1;
120+
while (j >= 0 && content[j] === '\\') {
121+
numBackslashes++;
122+
j--;
123+
}
124+
125+
// Quote is escaped if there's an odd number of backslashes before it
126+
const isEscaped = numBackslashes % 2 === 1;
127+
128+
if (!isEscaped) {
129+
if (!inQuotes) {
130+
// Starting a quoted section
131+
inQuotes = true;
132+
quoteChar = char;
133+
} else if (char === quoteChar) {
134+
// Ending a quoted section
135+
inQuotes = false;
136+
quoteChar = '';
137+
}
138+
}
139+
currentLine += char;
140+
continue;
141+
}
142+
143+
// Handle newlines
144+
if (char === '\n') {
145+
if (inQuotes) {
146+
// We're inside quotes, preserve the newline
147+
currentLine += char;
148+
} else {
149+
// We're not in quotes, this is the end of a line
150+
const [name, value] = parseEnvLine(currentLine);
151+
if (name !== '') {
152+
vars[name] = substituteEnvVars(value, vars, globalVars);
153+
}
154+
// Reset for next line
155+
currentLine = '';
156+
afterEquals = false;
104157
}
158+
} else {
159+
currentLine += char;
160+
}
161+
}
162+
163+
// Handle the last line if there's no trailing newline
164+
if (currentLine.trim() !== '') {
165+
const [name, value] = parseEnvLine(currentLine);
166+
if (name !== '') {
105167
vars[name] = substituteEnvVars(value, vars, globalVars);
106-
});
168+
}
169+
}
170+
107171
return vars;
108172
}
109173

@@ -112,7 +176,8 @@ function parseEnvLine(line: string): [string, string] {
112176
// https://github.com/motdotla/dotenv/blob/master/lib/main.js#L32
113177
// We don't use dotenv here because it loses ordering, which is
114178
// significant for substitution.
115-
const match = line.match(/^\s*(_*[a-zA-Z]\w*)\s*=\s*(.*?)?\s*$/);
179+
// Modified to handle multiline values by using 's' flag so $ matches before newlines in multiline strings
180+
const match = line.match(/^\s*(_*[a-zA-Z]\w*)\s*=\s*(.*?)?\s*$/s);
116181
if (!match) {
117182
return ['', ''];
118183
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { expect } from 'chai';
7+
import { parseEnvFile } from '../../../extension/common/variables/environment';
8+
9+
suite('Environment File Parsing Tests', () => {
10+
test('Should parse simple environment variables', () => {
11+
const content = 'VAR1=value1\nVAR2=value2';
12+
const result = parseEnvFile(content);
13+
14+
// eslint-disable-next-line @typescript-eslint/naming-convention
15+
expect(result).to.deep.equal({
16+
VAR1: 'value1',
17+
VAR2: 'value2',
18+
});
19+
});
20+
21+
test('Should parse single-quoted multiline values', () => {
22+
const content = "EXAMPLE_VAR='very long value\nwith new line , we need to get all the lines'";
23+
const result = parseEnvFile(content);
24+
25+
expect(result.EXAMPLE_VAR).to.equal('very long value\nwith new line , we need to get all the lines');
26+
});
27+
28+
test('Should parse double-quoted multiline values', () => {
29+
const content = 'EXAMPLE_VAR="very long value\nwith new line , we need to get all the lines"';
30+
const result = parseEnvFile(content);
31+
32+
expect(result.EXAMPLE_VAR).to.equal('very long value\nwith new line , we need to get all the lines');
33+
});
34+
35+
test('Should parse escaped newlines in single-quoted values', () => {
36+
const content = "VAR='line1\\nline2'";
37+
const result = parseEnvFile(content);
38+
39+
expect(result.VAR).to.equal('line1\nline2');
40+
});
41+
42+
test('Should parse escaped newlines in double-quoted values', () => {
43+
const content = 'VAR="line1\\nline2"';
44+
const result = parseEnvFile(content);
45+
46+
expect(result.VAR).to.equal('line1\nline2');
47+
});
48+
49+
test('Should handle multiple variables with multiline values', () => {
50+
const content = "VAR1='multiline\nvalue1'\nVAR2='multiline\nvalue2'";
51+
const result = parseEnvFile(content);
52+
53+
expect(result.VAR1).to.equal('multiline\nvalue1');
54+
expect(result.VAR2).to.equal('multiline\nvalue2');
55+
});
56+
57+
test('Should handle unquoted values', () => {
58+
const content = 'VAR=value_without_quotes';
59+
const result = parseEnvFile(content);
60+
61+
expect(result.VAR).to.equal('value_without_quotes');
62+
});
63+
64+
test('Should handle empty values', () => {
65+
const content = 'VAR=';
66+
const result = parseEnvFile(content);
67+
68+
expect(result.VAR).to.equal('');
69+
});
70+
71+
test('Should ignore lines without equals sign', () => {
72+
const content = 'VAR1=value1\nInvalid line\nVAR2=value2';
73+
const result = parseEnvFile(content);
74+
75+
// eslint-disable-next-line @typescript-eslint/naming-convention
76+
expect(result).to.deep.equal({
77+
VAR1: 'value1',
78+
VAR2: 'value2',
79+
});
80+
});
81+
82+
test('Should handle multiline value with multiple newlines', () => {
83+
const content = "VAR='line1\nline2\nline3\nline4'";
84+
const result = parseEnvFile(content);
85+
86+
expect(result.VAR).to.equal('line1\nline2\nline3\nline4');
87+
});
88+
89+
test('Should parse environment file as Buffer', () => {
90+
const content = Buffer.from("VAR='multiline\nvalue'");
91+
const result = parseEnvFile(content);
92+
93+
expect(result.VAR).to.equal('multiline\nvalue');
94+
});
95+
96+
test('Should handle whitespace around variable names and equals', () => {
97+
const content = " VAR1 = value1 \n VAR2='multiline\nvalue'";
98+
const result = parseEnvFile(content);
99+
100+
expect(result.VAR1).to.equal('value1');
101+
expect(result.VAR2).to.equal('multiline\nvalue');
102+
});
103+
});

0 commit comments

Comments
 (0)