Skip to content

Commit 21c540a

Browse files
Rewrite parser and fix escaped quotes in multiline values (#322)
1 parent 2ecc2da commit 21c540a

File tree

9 files changed

+145
-99
lines changed

9 files changed

+145
-99
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
],
1313
"require": {
1414
"php": "^5.4 || ^7.0",
15-
"phpoption/phpoption": "^1.5"
15+
"phpoption/phpoption": "^1.5",
16+
"symfony/polyfill-ctype": "^1.9"
1617
},
1718
"require-dev": {
1819
"phpunit/phpunit": "^4.8.35 || ^5.0 || ^6.0"

src/Lines.php

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static function process(array $lines)
2222
foreach ($lines as $line) {
2323
list($multiline, $line, $multilineBuffer) = self::multilineProcess($multiline, $line, $multilineBuffer);
2424

25-
if (!$multiline && !self::isComment($line) && self::looksLikeSetter($line)) {
25+
if (!$multiline && !self::isComment($line) && self::isSetter($line)) {
2626
$output[] = $line;
2727
}
2828
}
@@ -68,7 +68,11 @@ private static function multilineProcess($multiline, $line, array $buffer)
6868
*/
6969
private static function looksLikeMultilineStart($line)
7070
{
71-
return strpos($line, '="') !== false && substr_count($line, '"') === 1;
71+
if (strpos($line, '="') === false) {
72+
return false;
73+
}
74+
75+
return self::looksLikeMultilineStop($line) === false;
7276
}
7377

7478
/**
@@ -80,7 +84,31 @@ private static function looksLikeMultilineStart($line)
8084
*/
8185
private static function looksLikeMultilineStop($line)
8286
{
83-
return strpos($line, '"') !== false && substr_count($line, '="') === 0;
87+
if ($line === '"') {
88+
return true;
89+
}
90+
91+
foreach (self::getCharPairs(str_replace('\\\\', '', $line)) as $pair) {
92+
if ($pair[0] !== '\\' && $pair[0] !== '=' && $pair[1] === '"') {
93+
return true;
94+
}
95+
}
96+
97+
return false;
98+
}
99+
100+
/**
101+
* Get all pairs of adjacent characters within the line.
102+
*
103+
* @param string $line
104+
*
105+
* @return bool
106+
*/
107+
private static function getCharPairs($line)
108+
{
109+
$chars = str_split($line);
110+
111+
return array_map(null, $chars, array_slice($chars, 1));
84112
}
85113

86114
/**
@@ -104,7 +132,7 @@ private static function isComment($line)
104132
*
105133
* @return bool
106134
*/
107-
private static function looksLikeSetter($line)
135+
private static function isSetter($line)
108136
{
109137
return strpos($line, '=') !== false;
110138
}

src/Parser.php

Lines changed: 57 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
namespace Dotenv;
44

55
use Dotenv\Exception\InvalidFileException;
6-
use Dotenv\Regex\Regex;
76

87
class Parser
98
{
9+
const INITIAL_STATE = 0;
10+
const UNQUOTED_STATE = 1;
11+
const QUOTED_STATE = 2;
12+
const ESCAPE_STATE = 3;
13+
const WHITESPACE_STATE = 4;
14+
const COMMENT_STATE = 5;
15+
1016
/**
1117
* Parse the given environment variable entry into a name and value.
1218
*
13-
* Takes value as passed in by developer and:
14-
* - breaks up the line into a name and value,
15-
* - cleaning the value of quotes,
16-
* - cleaning the name of quotes.
17-
*
1819
* @param string $entry
1920
*
2021
* @throws \Dotenv\Exception\InvalidFileException
@@ -25,14 +26,12 @@ public static function parse($entry)
2526
{
2627
list($name, $value) = self::splitStringIntoParts($entry);
2728

28-
return [self::sanitiseName($name), self::sanitiseValue($value)];
29+
return [self::parseName($name), self::parseValue($value)];
2930
}
3031

3132
/**
3233
* Split the compound string into parts.
3334
*
34-
* If the `$line` contains an `=` sign, then we split it into 2 parts.
35-
*
3635
* @param string $line
3736
*
3837
* @throws \Dotenv\Exception\InvalidFileException
@@ -66,7 +65,7 @@ private static function splitStringIntoParts($line)
6665
*
6766
* @return string
6867
*/
69-
private static function sanitiseName($name)
68+
private static function parseName($name)
7069
{
7170
$name = trim(str_replace(['export ', '\'', '"'], '', $name));
7271

@@ -100,71 +99,60 @@ private static function isValidName($name)
10099
*
101100
* @return string|null
102101
*/
103-
private static function sanitiseValue($value)
102+
private static function parseValue($value)
104103
{
105104
if ($value === null || trim($value) === '') {
106105
return $value;
107106
}
108107

109-
if (self::beginsWithAQuote($value)) {
110-
return self::processQuotedValue($value);
111-
}
112-
113-
// Strip comments from the left
114-
$value = explode(' #', $value, 2)[0];
115-
116-
// Unquoted values cannot contain whitespace
117-
if (preg_match('/\s+/', $value) > 0) {
118-
// Check if value is a comment (usually triggered when empty value with comment)
119-
if (preg_match('/^#/', $value) > 0) {
120-
$value = '';
121-
} else {
122-
throw new InvalidFileException(
123-
self::getErrorMessage('an unexpected space', $value)
124-
);
108+
return array_reduce(str_split($value), function ($data, $char) use ($value) {
109+
switch ($data[1]) {
110+
case self::INITIAL_STATE:
111+
if ($char === '"') {
112+
return [$data[0], self::QUOTED_STATE];
113+
} elseif ($char === '#') {
114+
return [$data[0], self::COMMENT_STATE];
115+
} else {
116+
return [$data[0].$char, self::UNQUOTED_STATE];
117+
}
118+
case self::UNQUOTED_STATE:
119+
if ($char === '#') {
120+
return [$data[0], self::COMMENT_STATE];
121+
} elseif (ctype_space($char)) {
122+
return [$data[0], self::WHITESPACE_STATE];
123+
} else {
124+
return [$data[0].$char, self::UNQUOTED_STATE];
125+
}
126+
case self::QUOTED_STATE:
127+
if ($char === '"') {
128+
return [$data[0], self::WHITESPACE_STATE];
129+
} elseif ($char === '\\') {
130+
return [$data[0], self::ESCAPE_STATE];
131+
} else {
132+
return [$data[0].$char, self::QUOTED_STATE];
133+
}
134+
case self::ESCAPE_STATE:
135+
if ($char === '"' || $char === '\\') {
136+
return [$data[0].$char, self::QUOTED_STATE];
137+
} else {
138+
throw new InvalidFileException(
139+
self::getErrorMessage('an unexpected escape sequence', $value)
140+
);
141+
}
142+
case self::WHITESPACE_STATE:
143+
if ($char === '#') {
144+
return [$data[0], self::COMMENT_STATE];
145+
} elseif (!ctype_space($char)) {
146+
throw new InvalidFileException(
147+
self::getErrorMessage('unexpected whitespace', $value)
148+
);
149+
} else {
150+
return [$data[0], self::WHITESPACE_STATE];
151+
}
152+
case self::COMMENT_STATE:
153+
return [$data[0], self::COMMENT_STATE];
125154
}
126-
}
127-
128-
return $value;
129-
}
130-
131-
/**
132-
* Strips quotes from the environment variable value.
133-
*
134-
* @param string $value
135-
*
136-
* @return string
137-
*/
138-
private static function processQuotedValue($value)
139-
{
140-
$quote = $value[0];
141-
142-
$pattern = sprintf(
143-
'/^
144-
%1$s # match a quote at the start of the value
145-
( # capturing sub-pattern used
146-
(?: # we do not need to capture this
147-
[^%1$s\\\\]+ # any character other than a quote or backslash
148-
|\\\\\\\\ # or two backslashes together
149-
|\\\\%1$s # or an escaped quote e.g \"
150-
)* # as many characters that match the previous rules
151-
) # end of the capturing sub-pattern
152-
%1$s # and the closing quote
153-
.*$ # and discard any string after the closing quote
154-
/mx',
155-
$quote
156-
);
157-
158-
return Regex::replace($pattern, '$1', $value)
159-
->mapSuccess(function ($str) use ($quote) {
160-
return str_replace('\\\\', '\\', str_replace("\\$quote", $quote, $str));
161-
})
162-
->mapError(function ($err) use ($value) {
163-
throw new InvalidFileException(
164-
self::getErrorMessage(sprintf('a quote parsing error (%s)', $err), $value)
165-
);
166-
})
167-
->getSuccess();
155+
}, ['', self::INITIAL_STATE])[0];
168156
}
169157

170158
/**
@@ -183,16 +171,4 @@ private static function getErrorMessage($cause, $subject)
183171
strtok($subject, "\n")
184172
);
185173
}
186-
187-
/**
188-
* Determine if the given string begins with a quote.
189-
*
190-
* @param string $value
191-
*
192-
* @return bool
193-
*/
194-
private static function beginsWithAQuote($value)
195-
{
196-
return isset($value[0]) && ($value[0] === '"' || $value[0] === '\'');
197-
}
198174
}

tests/Dotenv/DotenvTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ public function testCommentedDotenvLoadsEnvironmentVars()
7272
$this->assertSame('a value with a # character & a quote " character inside quotes', getenv('CQUOTESWITHQUOTE'));
7373
$this->assertEmpty(getenv('CNULL'));
7474
$this->assertEmpty(getenv('EMPTY'));
75+
$this->assertEmpty(getenv('EMPTY2'));
76+
$this->assertSame('foo', getenv('FOOO'));
7577
}
7678

7779
public function testQuotedDotenvLoadsEnvironmentVars()
@@ -266,6 +268,13 @@ public function testDotenvAllowsSpecialCharacters()
266268
$this->assertSame('test some escaped characters like a quote " or maybe a backslash \\', getenv('SPVAR5'));
267269
}
268270

271+
public function testMutlilineLoading()
272+
{
273+
$dotenv = Dotenv::create($this->fixturesFolder, 'multiline.env');
274+
$dotenv->load();
275+
$this->assertSame("test\n test\"test\"\n test", getenv('TEST'));
276+
}
277+
269278
public function testDotenvAssertions()
270279
{
271280
$dotenv = Dotenv::create($this->fixturesFolder, 'assertions.env');

tests/Dotenv/LinesTest.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
class LinesTest extends TestCase
77
{
8-
public function testProcess()
8+
public function testProcessBasic()
99
{
1010
$content = file_get_contents(dirname(__DIR__).'/fixtures/env/assertions.env');
1111

@@ -14,13 +14,37 @@ public function testProcess()
1414
'ASSERTVAR2=""',
1515
'ASSERTVAR3="val3 "',
1616
'ASSERTVAR4="0" # empty looking value',
17-
'ASSERTVAR5=#foo',
17+
'ASSERTVAR5="#foo"',
1818
"ASSERTVAR6=\"val1\nval2\"",
1919
"ASSERTVAR7=\"\nval3\" #",
2020
"ASSERTVAR8=\"val3\n\"",
21-
"ASSERTVAR9=\"\n\"",
21+
"ASSERTVAR9=\"\n\n\"",
2222
];
2323

2424
$this->assertSame($expected, Lines::process(preg_split("/(\r\n|\n|\r)/", $content)));
2525
}
26+
27+
public function testProcessQuotes()
28+
{
29+
$content = file_get_contents(dirname(__DIR__).'/fixtures/env/multiline.env');
30+
31+
$expected = [
32+
"TEST=\"test\n test\\\"test\\\"\n test\"",
33+
];
34+
35+
$this->assertSame($expected, Lines::process(preg_split("/(\r\n|\n|\r)/", $content)));
36+
}
37+
38+
public function testProcessClosingSlash()
39+
{
40+
$lines = [
41+
'SPVAR5="test some escaped characters like a quote \" or maybe a backslash \\" # not escaped',
42+
];
43+
44+
$expected = [
45+
'SPVAR5="test some escaped characters like a quote \" or maybe a backslash \\" # not escaped',
46+
];
47+
48+
$this->assertSame($expected, $lines);
49+
}
2650
}

tests/Dotenv/ParserTest.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,17 @@ public function testExportParse()
2525
$this->assertSame(['FOO', 'bar baz'], Parser::parse('export FOO="bar baz"'));
2626
}
2727

28+
public function testClosingSlashParse()
29+
{
30+
$content = 'SPVAR5="test some escaped characters like a quote \\" or maybe a backslash \\\\" # not escaped';
31+
$expected = ['SPVAR5', 'test some escaped characters like a quote " or maybe a backslash \\'];
32+
33+
$this->assertSame($expected, Parser::parse($content));
34+
}
35+
2836
/**
2937
* @expectedException \Dotenv\Exception\InvalidFileException
30-
* @expectedExceptionMessage Failed to parse dotenv file due to an unexpected space. Failed at [bar baz].
38+
* @expectedExceptionMessage Failed to parse dotenv file due to unexpected whitespace. Failed at [bar baz].
3139
*/
3240
public function testParseInvalidSpaces()
3341
{
@@ -54,16 +62,10 @@ public function testParseInvalidName()
5462

5563
/**
5664
* @expectedException \Dotenv\Exception\InvalidFileException
57-
* @expectedExceptionMessage Failed to parse dotenv file due to a quote parsing error (PREG_
65+
* @expectedExceptionMessage Failed to parse dotenv file due to an unexpected escape sequence. Failed at ["iiiiviiiixiiiiviiii\n"].
5866
*/
59-
public function testParserFailsWithException()
67+
public function testParserEscaping()
6068
{
61-
$limit = (int) ini_get('pcre.backtrack_limit');
62-
63-
if ($limit > 1000000) {
64-
$this->markTestSkipped('System pcre.backtrack_limit too large.');
65-
}
66-
6769
Parser::parse('FOO_BAD="iiiiviiiixiiiiviiii\\n"');
6870
}
6971
}

tests/fixtures/env/assertions.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ ASSERTVAR2=""
44

55
ASSERTVAR3="val3 "
66
ASSERTVAR4="0" # empty looking value
7-
ASSERTVAR5=#foo
7+
ASSERTVAR5="#foo"
88
ASSERTVAR6="val1
99
val2"
1010
ASSERTVAR7="
@@ -14,4 +14,5 @@ val3" #
1414
ASSERTVAR8="val3
1515
"
1616
ASSERTVAR9="
17+
1718
"

tests/fixtures/env/commented.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ CSPACED="with spaces" # this is a comment
66
CQUOTES="a value with a # character" # this is a comment
77
CQUOTESWITHQUOTE="a value with a # character & a quote \" character inside quotes" # " this is a comment
88
EMPTY= # comment with empty variable
9+
EMPTY2=# comment with empty variable
10+
FOOO=foo# comment with no space
911
BOOLEAN=yes # (yes, no)
1012

1113
CNULL=

tests/fixtures/env/multiline.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
TEST="test
2+
test\"test\"
3+
test"

0 commit comments

Comments
 (0)