diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ea279b4..46ae50a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ The major version bump is due to upping the required PHP version from `8.5` to ` * Added the following methods to the `ImageInterface` interface: - `ImageInterface::toBase64()` - `ImageInterface::toDataUri()` +* Added dotenv loader. #### Changes diff --git a/src/mako/env/DotenvLoader.php b/src/mako/env/DotenvLoader.php new file mode 100644 index 000000000..3d2555c53 --- /dev/null +++ b/src/mako/env/DotenvLoader.php @@ -0,0 +1,268 @@ + 0 && ctype_space($value[$i - 1])) { + return rtrim(substr($value, 0, $i)); + } + } + + return $value; + } + + /** + * Interpolate variables in value. + */ + private function interpolateVariables(string $value, string $filePath, int $lineNumber): string + { + return preg_replace_callback( + '/(\\\\*)\$\{([A-Z_][A-Z0-9_]*)\}/i', + static function (array $matches) use ($filePath, $lineNumber): string { + $slashes = $matches[1]; + $key = $matches[2]; + + // Odd number of backslashes escapes the variable + + if ((strlen($slashes) % 2) === 1) { + return substr($slashes, 1) . "\${{$key}}"; + } + + // Even number of backslashes, we'll expand the variable + + return $slashes . ( + env($key) + ?? throw new EnvException(sprintf( + 'Undefined environment variable [ $%s ] in [ %s ] on line [ %s ].', + $key, + $filePath, + $lineNumber + )) + ); + }, + $value + ) ?? $value; + } + + /** + * Unescape values. + */ + private function unescape(string $value): string + { + return str_replace( + ['\\n', '\\r', '\\t', '\\"', '\\\\'], + ["\n", "\r", "\t", '"', '\\'], + $value + ); + } + + /** + * Parse value. + */ + private function parseValue(string $value, string $filePath, int $lineNumber): string + { + // Remove inline comments + + if (strpos($value, '#') !== false) { + $value = $this->stripInlineComment($value); + } + + if ($value === '') { + return ''; + } + + // Get first and last character + + $first = $value[0]; + $last = $value[strlen($value) - 1]; + + // Quoted value + + if ($first === '"' || $first === "'") { + if ($last !== $first) { + throw new EnvException(sprintf( + 'Unterminated quoted value in [ %s ] on line [ %s ].', + $filePath, + $lineNumber + )); + } + + $value = substr($value, 1, -1); + + if ($first === '"') { + if ($this->interpolateVariables && strpos($value, '${') !== false) { + $value = $this->interpolateVariables($value, $filePath, $lineNumber); + } + + $value = $this->unescape($value); + } + + return $value; + } + + // Unquoted value so we'll just trim it and return + + return trim($value); + } + + /** + * Loads the file into environment variables. + */ + public function load(string $filePath, string $keyPrefix = ''): void + { + $lines = file($filePath, FILE_IGNORE_NEW_LINES); + + if ($lines === false) { + throw new EnvException(sprintf('Failed to read env file [ %s ].', $filePath)); + } + + foreach ($lines as $key => $line) { + $lineNumber = $key + 1; + + // Check for NUL byte + + if (str_contains($line, "\0")) { + throw new EnvException(sprintf( + 'NUL byte detected in [ %s ] on line [ %s ].', + $filePath, + $lineNumber + )); + } + + // Strip UTF-8 BOM from first line + + if ($lineNumber === 1 && str_starts_with($line, "\xEF\xBB\xBF")) { + $line = substr($line, 3); + } + + $line = trim($line); + + // Skip empty lines and lines that are comments + + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + // Strip "export" line prefix + + if (str_starts_with($line, 'export ')) { + $line = trim(substr($line, 7)); + } + + // Split key and value + + $equalPosition = strpos($line, '='); + + if ($equalPosition === false) { + throw new EnvException(sprintf( + 'Invalid env declaration in [ %s ] on line [ %s ].', + $filePath, + $lineNumber + )); + } + + $key = trim(substr($line, 0, $equalPosition)); + $value = trim(substr($line, $equalPosition + 1)); + + if ($key === '' || !preg_match('/^(?:[A-Z][A-Z0-9_]*|_[A-Z0-9_]+)$/i', $key)) { + throw new EnvException(sprintf( + 'Invalid key [ %s ] in [ %s ] on line [ %s ].', + ($key ?: 'empty'), + $filePath, + $lineNumber + )); + } + + $key = "{$keyPrefix}{$key}"; + + // Should we skip overriding existing variables? + + if ($this->overrideExisting === false && (array_key_exists($key, $_ENV) || getenv($key) !== false)) { + continue; + } + + // Parse value and put it into environment + + if ($value !== '') { + $value = $this->parseValue($value, $filePath, $lineNumber); + } + + if ($this->usePutEnv) { + putenv("{$key}={$value}"); + } + + $_ENV[$key] = $value; + } + } +} diff --git a/src/mako/env/exceptions/EnvException.php b/src/mako/env/exceptions/EnvException.php new file mode 100644 index 000000000..310f854bd --- /dev/null +++ b/src/mako/env/exceptions/EnvException.php @@ -0,0 +1,18 @@ + $value, diff --git a/tests/unit/env/DotenvLoaderTest.php b/tests/unit/env/DotenvLoaderTest.php new file mode 100644 index 000000000..06a8e812f --- /dev/null +++ b/tests/unit/env/DotenvLoaderTest.php @@ -0,0 +1,194 @@ +load(__DIR__ . '/fixtures/basic.env', 'MAKO_TEST_'); + + $this->assertSame('value#1', $_ENV['MAKO_TEST_KEY1']); + $this->assertSame('value#2', $_ENV['MAKO_TEST_KEY2']); + $this->assertSame('value#3', $_ENV['MAKO_TEST_KEY3']); + $this->assertSame('var:${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY4']); + $this->assertSame('var:${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY5']); + $this->assertSame('var:${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY6']); + $this->assertSame('value#7', $_ENV['MAKO_TEST_KEY7']); + $this->assertSame('value#8', $_ENV['MAKO_TEST_KEY8']); + $this->assertSame('value#9', $_ENV['MAKO_TEST_KEY9']); + $this->assertSame('value#10', $_ENV['MAKO_TEST_KEY10']); + $this->assertSame('value#11', $_ENV['MAKO_TEST_KEY11']); + $this->assertSame('value#12', $_ENV['MAKO_TEST_KEY12']); + $this->assertSame('value#13', $_ENV['MAKO_TEST_KEY13']); + $this->assertSame('value#14', $_ENV['MAKO_TEST_KEY14']); + $this->assertSame('value#15', $_ENV['MAKO_TEST_KEY15']); + $this->assertSame('foo\nbar', $_ENV['MAKO_TEST_KEY16']); + $this->assertSame('foo\nbar', $_ENV['MAKO_TEST_KEY17']); + $this->assertSame("foo\nbar", $_ENV['MAKO_TEST_KEY18']); + $this->assertSame('var:\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY19']); + $this->assertSame('var:\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY20']); + $this->assertSame('var:\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY21']); + $this->assertSame('var:\\\\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY22']); + $this->assertSame('var:\\\\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY23']); + $this->assertSame('var:\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY24']); + $this->assertSame('value#25', $_ENV['MAKO_TEST_KEY25']); + $this->assertSame('value#26', $_ENV['MAKO_TEST_KEY26']); + $this->assertSame('value#27', $_ENV['MAKO_TEST_KEY27']); + + foreach (range(1, 27) as $num) { + unset($_ENV["MAKO_TEST_KEY{$num}"]); + } + } + + /** + * + */ + public function testLoadBasicWithInterpolatedVariables(): void + { + (new DotenvLoader(interpolateVariables: true)) + ->load(__DIR__ . '/fixtures/basic.env', 'MAKO_TEST_'); + + $this->assertSame('value#1', $_ENV['MAKO_TEST_KEY1']); + $this->assertSame('value#2', $_ENV['MAKO_TEST_KEY2']); + $this->assertSame('value#3', $_ENV['MAKO_TEST_KEY3']); + $this->assertSame('var:${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY4']); + $this->assertSame('var:${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY5']); + $this->assertSame('var:value#1', $_ENV['MAKO_TEST_KEY6']); + $this->assertSame('value#7', $_ENV['MAKO_TEST_KEY7']); + $this->assertSame('value#8', $_ENV['MAKO_TEST_KEY8']); + $this->assertSame('value#9', $_ENV['MAKO_TEST_KEY9']); + $this->assertSame('value#10', $_ENV['MAKO_TEST_KEY10']); + $this->assertSame('value#11', $_ENV['MAKO_TEST_KEY11']); + $this->assertSame('value#12', $_ENV['MAKO_TEST_KEY12']); + $this->assertSame('value#13', $_ENV['MAKO_TEST_KEY13']); + $this->assertSame('value#14', $_ENV['MAKO_TEST_KEY14']); + $this->assertSame('value#15', $_ENV['MAKO_TEST_KEY15']); + $this->assertSame('foo\nbar', $_ENV['MAKO_TEST_KEY16']); + $this->assertSame('foo\nbar', $_ENV['MAKO_TEST_KEY17']); + $this->assertSame("foo\nbar", $_ENV['MAKO_TEST_KEY18']); + $this->assertSame('var:\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY19']); + $this->assertSame('var:\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY20']); + $this->assertSame('var:${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY21']); + $this->assertSame('var:\\\\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY22']); + $this->assertSame('var:\\\\${MAKO_TEST_KEY1}', $_ENV['MAKO_TEST_KEY23']); + $this->assertSame('var:\value#1', $_ENV['MAKO_TEST_KEY24']); + $this->assertSame('value#25', $_ENV['MAKO_TEST_KEY25']); + $this->assertSame('value#26', $_ENV['MAKO_TEST_KEY26']); + $this->assertSame('value#27', $_ENV['MAKO_TEST_KEY27']); + + foreach (range(1, 27) as $num) { + unset($_ENV["MAKO_TEST_KEY{$num}"]); + } + } + + /** + * + */ + public function testNoOverride(): void + { + (new DotenvLoader) + ->load(__DIR__ . '/fixtures/override.env', 'MAKO_TEST_'); + + $this->assertSame('foo', $_ENV['MAKO_TEST_KEY1']); + + unset($_ENV['MAKO_TEST_KEY1']); + } + + /** + * + */ + public function testWithOverride(): void + { + (new DotenvLoader(overrideExisting: true)) + ->load(__DIR__ . '/fixtures/override.env', 'MAKO_TEST_'); + + $this->assertSame('bar', $_ENV['MAKO_TEST_KEY1']); + + unset($_ENV['MAKO_TEST_KEY1']); + } + + /** + * + */ + public function testInvalidDeclaration(): void + { + $file = __DIR__ . '/fixtures/invalid-declaration.env'; + + $this->expectException(EnvException::class); + $this->expectExceptionMessageIs('Invalid env declaration in [ ' . $file . ' ] on line [ 1 ].'); + + (new DotenvLoader)->load($file, 'MAKO_TEST_'); + } + + /** + * + */ + public function testInvalidKey(): void + { + $file = __DIR__ . '/fixtures/invalid-key.env'; + + $this->expectException(EnvException::class); + $this->expectExceptionMessageIs('Invalid key [ $KEY1 ] in [ ' . $file . ' ] on line [ 1 ].'); + + (new DotenvLoader) + ->load($file, 'MAKO_TEST_'); + } + + /** + * + */ + public function testUnterminatedDoubleQuote(): void + { + $file = __DIR__ . '/fixtures/unterminated-double-quote.env'; + + $this->expectException(EnvException::class); + $this->expectExceptionMessageIs('Unterminated quoted value in [ ' . $file . ' ] on line [ 1 ].'); + + (new DotenvLoader) + ->load($file, 'MAKO_TEST_'); + } + + /** + * + */ + public function testUnterminatedSingleQuote(): void + { + $file = __DIR__ . '/fixtures/unterminated-single-quote.env'; + + $this->expectException(EnvException::class); + $this->expectExceptionMessageIs('Unterminated quoted value in [ ' . $file . ' ] on line [ 1 ].'); + + (new DotenvLoader) + ->load($file, 'MAKO_TEST_'); + } + + /** + * + */ + public function testUndefinedVariable(): void + { + $file = __DIR__ . '/fixtures/undefined-variable.env'; + + $this->expectException(EnvException::class); + $this->expectExceptionMessageIs('Undefined environment variable [ $MAKO_TEST_FOO_BAR_BAZ ] in [ ' . $file . ' ] on line [ 1 ].'); + + (new DotenvLoader(interpolateVariables: true)) + ->load($file, 'MAKO_TEST_'); + } +} diff --git a/tests/unit/env/fixtures/basic.env b/tests/unit/env/fixtures/basic.env new file mode 100644 index 000000000..9ddf86ad2 --- /dev/null +++ b/tests/unit/env/fixtures/basic.env @@ -0,0 +1,35 @@ +KEY1=value#1 #comment +KEY2='value#2' #comment +KEY3="value#3" #comment + +KEY4=var:${MAKO_TEST_KEY1} +KEY5='var:${MAKO_TEST_KEY1}' +KEY6="var:${MAKO_TEST_KEY1}" + +KEY7= value#7 +KEY8= 'value#8' +KEY9= "value#9" + +KEY10 =value#10 +KEY11 ='value#11' +KEY12 ="value#12" + +KEY13 = value#13 +KEY14 = 'value#14' +KEY15 = "value#15" + +KEY16 = foo\nbar +KEY17 = 'foo\nbar' +KEY18 = "foo\nbar" + +KEY19=var:\${MAKO_TEST_KEY1} +KEY20='var:\${MAKO_TEST_KEY1}' +KEY21="var:\${MAKO_TEST_KEY1}" + +KEY22=var:\\${MAKO_TEST_KEY1} +KEY23='var:\\${MAKO_TEST_KEY1}' +KEY24="var:\\${MAKO_TEST_KEY1}" + +export KEY25= value#25 +export KEY26= 'value#26' +export KEY27= "value#27" diff --git a/tests/unit/env/fixtures/invalid-declaration.env b/tests/unit/env/fixtures/invalid-declaration.env new file mode 100644 index 000000000..7aa1ee343 --- /dev/null +++ b/tests/unit/env/fixtures/invalid-declaration.env @@ -0,0 +1 @@ +KEY1 diff --git a/tests/unit/env/fixtures/invalid-key.env b/tests/unit/env/fixtures/invalid-key.env new file mode 100644 index 000000000..65eb09bfa --- /dev/null +++ b/tests/unit/env/fixtures/invalid-key.env @@ -0,0 +1 @@ +$KEY1=foo diff --git a/tests/unit/env/fixtures/override.env b/tests/unit/env/fixtures/override.env new file mode 100644 index 000000000..790b34cad --- /dev/null +++ b/tests/unit/env/fixtures/override.env @@ -0,0 +1,2 @@ +KEY1=foo +KEY1=bar diff --git a/tests/unit/env/fixtures/undefined-variable.env b/tests/unit/env/fixtures/undefined-variable.env new file mode 100644 index 000000000..eaf207156 --- /dev/null +++ b/tests/unit/env/fixtures/undefined-variable.env @@ -0,0 +1 @@ +KEY1="${MAKO_TEST_FOO_BAR_BAZ}" diff --git a/tests/unit/env/fixtures/unterminated-double-quote.env b/tests/unit/env/fixtures/unterminated-double-quote.env new file mode 100644 index 000000000..701130fd2 --- /dev/null +++ b/tests/unit/env/fixtures/unterminated-double-quote.env @@ -0,0 +1 @@ +KEY1="value diff --git a/tests/unit/env/fixtures/unterminated-single-quote.env b/tests/unit/env/fixtures/unterminated-single-quote.env new file mode 100644 index 000000000..88c5d7fdf --- /dev/null +++ b/tests/unit/env/fixtures/unterminated-single-quote.env @@ -0,0 +1 @@ +KEY1='value