diff --git a/Dotenv.php b/Dotenv.php index a667365..750e94c 100644 --- a/Dotenv.php +++ b/Dotenv.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Dotenv; +use Symfony\Component\Dotenv\Exception\ExceptionInterface; use Symfony\Component\Dotenv\Exception\FormatException; use Symfony\Component\Dotenv\Exception\FormatExceptionContext; use Symfony\Component\Dotenv\Exception\PathException; @@ -37,6 +38,7 @@ final class Dotenv private array $values = []; private array $prodEnvs = ['prod']; private bool $usePutenv = false; + private bool $resolveVars = true; public function __construct( private string $envKey = 'APP_ENV', @@ -78,7 +80,18 @@ public function usePutenv(bool $usePutenv = true): static */ public function load(string $path, string ...$extraPaths): void { - $this->doLoad(false, \func_get_args()); + if ($extraPaths) { + $previousResolveVars = $this->resolveVars; + $this->resolveVars = false; + try { + $this->doLoad(false, \func_get_args()); + } finally { + $this->resolveVars = $previousResolveVars; + } + $this->resolveLoadedVars(); + } else { + $this->doLoad(false, [$path]); + } } /** @@ -100,33 +113,40 @@ public function loadEnv(string $path, ?string $envKey = null, string $defaultEnv { $this->populatePath($path); - $k = $envKey ?? $this->envKey; + $previousResolveVars = $this->resolveVars; + $this->resolveVars = false; + try { + $k = $envKey ?? $this->envKey; - if (is_file($path) || !is_file($p = "$path.dist")) { - $this->doLoad($overrideExistingVars, [$path]); - } else { - $this->doLoad($overrideExistingVars, [$p]); - } + if (is_file($path) || !is_file($p = "$path.dist")) { + $this->doLoad($overrideExistingVars, [$path]); + } else { + $this->doLoad($overrideExistingVars, [$p]); + } - if (null === $env = $_SERVER[$k] ?? $_ENV[$k] ?? null) { - $this->populate([$k => $env = $defaultEnv], $overrideExistingVars); - } + if (null === $env = $_SERVER[$k] ?? $_ENV[$k] ?? null) { + $this->populate([$k => $env = $defaultEnv], $overrideExistingVars); + } - if (!\in_array($env, $testEnvs, true) && is_file($p = "$path.local")) { - $this->doLoad($overrideExistingVars, [$p]); - $env = $_SERVER[$k] ?? $_ENV[$k] ?? $env; - } + if (!\in_array($env, $testEnvs, true) && is_file($p = "$path.local")) { + $this->doLoad($overrideExistingVars, [$p]); + $env = $_SERVER[$k] ?? $_ENV[$k] ?? $env; + } - if ('local' === $env) { - return; - } + if ('local' === $env) { + return; + } - if (is_file($p = "$path.$env")) { - $this->doLoad($overrideExistingVars, [$p]); - } + if (is_file($p = "$path.$env")) { + $this->doLoad($overrideExistingVars, [$p]); + } - if (is_file($p = "$path.$env.local")) { - $this->doLoad($overrideExistingVars, [$p]); + if (is_file($p = "$path.$env.local")) { + $this->doLoad($overrideExistingVars, [$p]); + } + } finally { + $this->resolveVars = $previousResolveVars; + $this->resolveLoadedVars(); } } @@ -168,7 +188,18 @@ public function bootEnv(string $path, string $defaultEnv = 'dev', array $testEnv */ public function overload(string $path, string ...$extraPaths): void { - $this->doLoad(true, \func_get_args()); + if ($extraPaths) { + $previousResolveVars = $this->resolveVars; + $this->resolveVars = false; + try { + $this->doLoad(true, \func_get_args()); + } finally { + $this->resolveVars = $previousResolveVars; + } + $this->resolveLoadedVars(); + } else { + $this->doLoad(true, [$path]); + } } /** @@ -322,7 +353,11 @@ private function lexValue(): string } } while ("'" !== $this->data[$this->cursor + $len]); - $v .= substr($this->data, 1 + $this->cursor, $len - 1); + $singleQuoted = substr($this->data, 1 + $this->cursor, $len - 1); + if (!$this->resolveVars) { + $singleQuoted = str_replace('$', "\x00", $singleQuoted); + } + $v .= $singleQuoted; $this->cursor += 1 + $len; } elseif ('"' === $this->data[$this->cursor]) { $value = ''; @@ -342,9 +377,11 @@ private function lexValue(): string ++$this->cursor; $value = str_replace(['\\"', '\r', '\n'], ['"', "\r", "\n"], $value); $resolvedValue = $value; - $resolvedValue = $this->resolveCommands($resolvedValue, $loadedVars); - $resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars); - $resolvedValue = str_replace('\\\\', '\\', $resolvedValue); + if ($this->resolveVars) { + $resolvedValue = $this->resolveCommands($resolvedValue, $loadedVars); + $resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars); + $resolvedValue = str_replace('\\\\', '\\', $resolvedValue); + } $v .= $resolvedValue; } else { $value = ''; @@ -365,11 +402,13 @@ private function lexValue(): string } $value = rtrim($value); $resolvedValue = $value; - $resolvedValue = $this->resolveCommands($resolvedValue, $loadedVars); - $resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars); - $resolvedValue = str_replace('\\\\', '\\', $resolvedValue); + if ($this->resolveVars) { + $resolvedValue = $this->resolveCommands($resolvedValue, $loadedVars); + $resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars); + $resolvedValue = str_replace('\\\\', '\\', $resolvedValue); + } - if ($resolvedValue === $value && preg_match('/\s+/', $value)) { + if ($resolvedValue === $value && preg_match('/\s+/', $value) && !str_contains($value, '$')) { throw $this->createFormatException('A value containing spaces must be surrounded by quotes'); } @@ -558,10 +597,66 @@ private function doLoad(bool $overrideExistingVars, array $paths): void throw new FormatException('Loading files starting with a byte-order-mark (BOM) is not supported.', new FormatExceptionContext($data, $path, 1, 0)); } + if (str_contains($data, "\0")) { + throw new FormatException('Loading files containing NUL bytes is not supported.', new FormatExceptionContext($data, $path, 1, 0)); + } + $this->populate($this->parse($data, $path), $overrideExistingVars); } } + private function resolveLoadedVars(): void + { + $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); + unset($loadedVars['']); + + $this->values = []; + $this->path = ''; + $this->data = ''; + $this->lineno = 0; + $this->cursor = 0; + $this->end = 0; + + for ($pass = 0; $pass < 5; ++$pass) { + $resolved = []; + foreach ($loadedVars as $name => $_) { + if ('SYMFONY_DOTENV_VARS' === $name) { + continue; + } + if (!str_contains($value = $_ENV[$name] ?? '', '$')) { + continue; + } + $resolvedValue = $this->resolveCommands($value, $loadedVars); + $resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars); + $resolvedValue = str_replace('\\\\', '\\', $resolvedValue); + if ($value !== $resolvedValue) { + $resolved[$name] = $resolvedValue; + } + } + if (!$resolved) { + break; + } + $this->populate($resolved, true); + } + if (5 === $pass && $resolved) { + throw new class('Too many levels of variable indirection in env vars: '.implode(', ', array_keys($resolved)).'.') extends \LogicException implements ExceptionInterface {}; + } + + // Restore literal $ signs that were protected from resolution (from single-quoted strings) + $restored = []; + foreach ($loadedVars as $name => $_) { + if ('SYMFONY_DOTENV_VARS' !== $name && str_contains($value = $_ENV[$name] ?? '', "\x00")) { + $restored[$name] = str_replace("\x00", '$', $value); + } + } + if ($restored) { + $this->populate($restored, true); + } + + $this->values = []; + unset($this->path, $this->data, $this->lineno, $this->cursor, $this->end); + } + private function populatePath(string $path): void { $_ENV['SYMFONY_DOTENV_PATH'] = $_SERVER['SYMFONY_DOTENV_PATH'] = $path; diff --git a/Tests/DotenvTest.php b/Tests/DotenvTest.php index 69a2e06..7c3d020 100644 --- a/Tests/DotenvTest.php +++ b/Tests/DotenvTest.php @@ -360,6 +360,209 @@ public function testLoadEnv() rmdir($tmpdir); } + public function testLoadEnvResolvesVariablesFromOverriddenFiles() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['REDIS_HOST'], $_ENV['LOCK_DSN'], $_ENV['HOST'], $_ENV['DSN'], $_ENV['FOO'], $_ENV['BAR'], $_ENV['TEST_APP_ENV']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['REDIS_HOST'], $_SERVER['LOCK_DSN'], $_SERVER['HOST'], $_SERVER['DSN'], $_SERVER['FOO'], $_SERVER['BAR'], $_SERVER['TEST_APP_ENV']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('REDIS_HOST'); + putenv('LOCK_DSN'); + putenv('HOST'); + putenv('DSN'); + putenv('FOO'); + putenv('BAR'); + putenv('TEST_APP_ENV'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path = tempnam($tmpdir, 'sf-'); + + // .env defines REDIS_HOST and LOCK_DSN referencing it + file_put_contents($path, "REDIS_HOST=localhost\nLOCK_DSN=redis://\${REDIS_HOST}"); + // .env.local overrides REDIS_HOST + file_put_contents("$path.local", 'REDIS_HOST=aaa'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('aaa', getenv('REDIS_HOST')); + $this->assertSame('redis://aaa', getenv('LOCK_DSN')); + + // backslash + variable in double-quoted value must resolve correctly + file_put_contents($path, "HOST=localhost\nDSN=\"path\\\\\${HOST}\""); + file_put_contents("$path.local", 'HOST=override'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('override', getenv('HOST')); + $this->assertSame('path\\override', getenv('DSN')); + + // single-quoted $ must stay literal and not be resolved + file_put_contents($path, "BAR=hello\nFOO='\$BAR'"); + file_put_contents("$path.local", 'BAR=world'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('$BAR', getenv('FOO')); + $this->assertSame('world', getenv('BAR')); + + $resetContext(); + unlink("$path.local"); + unlink($path); + rmdir($tmpdir); + } + + public function testLoadMultiplePathsResolvesVariables() + { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['HOST'], $_ENV['URL']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['HOST'], $_SERVER['URL']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('HOST'); + putenv('URL'); + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path1 = tempnam($tmpdir, 'sf-'); + $path2 = tempnam($tmpdir, 'sf-'); + + file_put_contents($path1, "HOST=localhost\nURL=http://\${HOST}"); + file_put_contents($path2, 'HOST=production'); + + (new Dotenv())->usePutenv()->load($path1, $path2); + + $this->assertSame('production', getenv('HOST')); + $this->assertSame('http://production', getenv('URL')); + + putenv('SYMFONY_DOTENV_VARS'); + putenv('HOST'); + putenv('URL'); + unlink($path1); + unlink($path2); + rmdir($tmpdir); + } + + public function testLoadEnvResolvesCommandsWithOverriddenVars() + { + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('This test cannot be run on Windows.'); + } + + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['HOST'], $_ENV['RESOLVED'], $_ENV['TEST_APP_ENV']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['HOST'], $_SERVER['RESOLVED'], $_SERVER['TEST_APP_ENV']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('HOST'); + putenv('RESOLVED'); + putenv('TEST_APP_ENV'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path = tempnam($tmpdir, 'sf-'); + + file_put_contents($path, "HOST=original\nRESOLVED=\"\$(echo \${HOST})\""); + file_put_contents("$path.local", 'HOST=overridden'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('overridden', getenv('HOST')); + $this->assertSame('overridden', getenv('RESOLVED')); + + $resetContext(); + unlink("$path.local"); + unlink($path); + rmdir($tmpdir); + } + + public function testLoadEnvResolvesUnquotedCommandsWithOverriddenVars() + { + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('This test cannot be run on Windows.'); + } + + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['HOST'], $_ENV['RESOLVED'], $_ENV['TEST_APP_ENV']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['HOST'], $_SERVER['RESOLVED'], $_SERVER['TEST_APP_ENV']); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path = tempnam($tmpdir, 'sf-'); + + file_put_contents($path, "HOST=original\nRESOLVED=\$(echo \${HOST})"); + file_put_contents("$path.local", 'HOST=overridden'); + + $resetContext(); + (new Dotenv())->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('overridden', $_ENV['HOST']); + $this->assertSame('overridden', $_ENV['RESOLVED']); + + $resetContext(); + unlink("$path.local"); + unlink($path); + rmdir($tmpdir); + } + + public function testLoadEnvThrowsOnCircularVariableReferences() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['A'], $_ENV['B'], $_ENV['TEST_APP_ENV']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['A'], $_SERVER['B'], $_SERVER['TEST_APP_ENV']); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path1 = tempnam($tmpdir, 'sf-'); + $path2 = tempnam($tmpdir, 'sf-'); + + // Mutual references that grow each pass — never stabilize + file_put_contents($path1, 'A=${B}x'); + file_put_contents($path2, 'B=${A}y'); + + $resetContext(); + try { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Too many levels of variable indirection'); + (new Dotenv())->load($path1, $path2); + } finally { + $resetContext(); + unlink($path1); + unlink($path2); + rmdir($tmpdir); + } + } + + public function testLoadEnvUnquotedSpaceWithVariableDoesNotThrow() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['PREFIX'], $_ENV['LABEL'], $_ENV['TEST_APP_ENV']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['PREFIX'], $_SERVER['LABEL'], $_SERVER['TEST_APP_ENV']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('PREFIX'); + putenv('LABEL'); + putenv('TEST_APP_ENV'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $path = tempnam($tmpdir, 'sf-'); + + // Unquoted value with a space and a variable reference + file_put_contents($path, "PREFIX=hello\nLABEL=\${PREFIX} world"); + file_put_contents("$path.local", 'PREFIX=overridden'); + + $resetContext(); + (new Dotenv())->usePutenv()->loadEnv($path, 'TEST_APP_ENV'); + + $this->assertSame('overridden', getenv('PREFIX')); + $this->assertSame('overridden world', getenv('LABEL')); + + $resetContext(); + unlink("$path.local"); + unlink($path); + rmdir($tmpdir); + } + public function testOverload() { unset($_ENV['FOO']);