diff --git a/Dotenv.php b/Dotenv.php index c8a4737..bbc5d04 100644 --- a/Dotenv.php +++ b/Dotenv.php @@ -36,8 +36,12 @@ final class Dotenv private string $data; private int $end; private array $values = []; + private array $overriddenValues = []; + private array $loadedRawVars = []; private array $prodEnvs = ['prod']; private bool $usePutenv = false; + private bool $deferPutenv = false; + private array $pendingPutenv = []; public function __construct( private string $envKey = 'APP_ENV', @@ -79,8 +83,13 @@ public function usePutenv(bool $usePutenv = true): static */ public function load(string $path, string ...$extraPaths): void { - $this->doLoad(false, \func_get_args()); - $this->resolveLoadedVars(); + $this->deferPutenv = true; + try { + $this->doLoad(false, \func_get_args()); + $this->resolveLoadedVars(); + } finally { + $this->deferPutenv = false; + } } /** @@ -102,6 +111,7 @@ public function loadEnv(string $path, ?string $envKey = null, string $defaultEnv { $this->populatePath($path); + $this->deferPutenv = true; try { $k = $envKey ?? $this->envKey; @@ -137,7 +147,11 @@ public function loadEnv(string $path, ?string $envKey = null, string $defaultEnv $this->doLoad($overrideExistingVars, [$p]); } } finally { - $this->resolveLoadedVars(); + try { + $this->resolveLoadedVars(); + } finally { + $this->deferPutenv = false; + } } } @@ -179,8 +193,13 @@ public function bootEnv(string $path, string $defaultEnv = 'dev', array $testEnv */ public function overload(string $path, string ...$extraPaths): void { - $this->doLoad(true, \func_get_args()); - $this->resolveLoadedVars(); + $this->deferPutenv = true; + try { + $this->doLoad(true, \func_get_args()); + $this->resolveLoadedVars(); + } finally { + $this->deferPutenv = false; + } } /** @@ -206,7 +225,11 @@ public function populate(array $values, bool $overrideExistingVars = false): voi } if ($this->usePutenv) { - putenv("$name=$value"); + if ($this->deferPutenv) { + $this->pendingPutenv[$name] = true; + } else { + putenv("$name=$value"); + } } $_ENV[$name] = $value; @@ -643,7 +666,21 @@ private function doLoad(bool $overrideExistingVars, array $paths): void throw new FormatException('Loading files containing NUL bytes is not supported.', new FormatExceptionContext($data, $path, 1, 0)); } - $this->populate($this->parseRaw($data, $path), $overrideExistingVars); + $values = $this->parseRaw($data, $path); + + $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); + unset($loadedVars['']); + + foreach ($values as $name => $_) { + if (!isset($this->overriddenValues[$name]) && isset($_ENV[$name])) { + $this->overriddenValues[$name] = $_ENV[$name]; + } + if (isset($loadedVars[$name]) || $overrideExistingVars || !isset($_ENV[$name])) { + $this->loadedRawVars[$name] = true; + } + } + + $this->populate($values, $overrideExistingVars); } } @@ -696,6 +733,10 @@ private function resolveLoadedVars(): void $loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '')); unset($loadedVars['']); + $rawVars = $this->loadedRawVars; + $this->loadedRawVars = []; + unset($rawVars['SYMFONY_DOTENV_VARS']); + $this->values = []; $this->path = ''; $this->data = ''; @@ -707,10 +748,7 @@ private function resolveLoadedVars(): void // (e.g. MY_VAR="${MY_VAR:-default}") so their own raw value is hidden // during resolution, allowing the default to trigger correctly. $selfReferencingVars = []; - foreach ($loadedVars as $name => $_) { - if ('SYMFONY_DOTENV_VARS' === $name) { - continue; - } + foreach ($rawVars as $name => $_) { $value = $_ENV[$name] ?? ''; if (str_contains($value, '$') && preg_match('/\$\{?'.preg_quote($name, '/').'(?![A-Za-z0-9_])/', $value)) { $selfReferencingVars[$name] = true; @@ -719,10 +757,7 @@ private function resolveLoadedVars(): void for ($pass = 0; $pass < 5; ++$pass) { $resolved = []; - foreach ($loadedVars as $name => $_) { - if ('SYMFONY_DOTENV_VARS' === $name) { - continue; - } + foreach ($rawVars as $name => $_) { if (!str_contains($value = $_ENV[$name] ?? '', '$')) { continue; } @@ -730,10 +765,19 @@ private function resolveLoadedVars(): void if (isset($selfReferencingVars[$name])) { $envBackup = $_ENV[$name] ?? null; $serverBackup = $_SERVER[$name] ?? null; - unset($_ENV[$name], $_SERVER[$name]); + if (isset($this->overriddenValues[$name])) { + $_ENV[$name] = $this->overriddenValues[$name]; + $_SERVER[$name] = $this->overriddenValues[$name]; + } else { + unset($_ENV[$name], $_SERVER[$name]); + } if ($this->usePutenv) { - $getenvBackup = $this->usePutenv ? (string) getenv($name) : null; - putenv($name); + $getenvBackup = (string) getenv($name); + if (isset($this->overriddenValues[$name])) { + putenv("$name={$this->overriddenValues[$name]}"); + } else { + putenv($name); + } } } @@ -767,10 +811,7 @@ private function resolveLoadedVars(): void // Restore literal $ signs and unescape backslashes $restored = []; - foreach ($loadedVars as $name => $_) { - if ('SYMFONY_DOTENV_VARS' === $name) { - continue; - } + foreach ($rawVars as $name => $_) { $value = $_ENV[$name] ?? ''; if ($value !== $newValue = str_replace(["\x00", '\\\\'], ['$', '\\'], $value)) { $restored[$name] = $newValue; @@ -780,7 +821,15 @@ private function resolveLoadedVars(): void $this->populate($restored, true); } + if ($this->usePutenv && $this->pendingPutenv) { + foreach ($this->pendingPutenv as $name => $_) { + putenv($name.'='.($_ENV[$name] ?? '')); + } + $this->pendingPutenv = []; + } + $this->values = []; + $this->overriddenValues = []; unset($this->path, $this->data, $this->lineno, $this->cursor, $this->end); } @@ -788,7 +837,12 @@ private function populatePath(string $path): void { $_ENV['SYMFONY_DOTENV_PATH'] = $_SERVER['SYMFONY_DOTENV_PATH'] = $path; - if ($this->usePutenv) { + if (!$this->usePutenv) { + return; + } + if ($this->deferPutenv) { + $this->pendingPutenv['SYMFONY_DOTENV_PATH'] = true; + } else { putenv('SYMFONY_DOTENV_PATH='.$path); } } diff --git a/Tests/DotenvTest.php b/Tests/DotenvTest.php index 8773bb0..66d63d8 100644 --- a/Tests/DotenvTest.php +++ b/Tests/DotenvTest.php @@ -235,6 +235,41 @@ public function testLoad() $this->assertSame('BAZ', $bar); } + public function testLoadDoesNotReResolveAlreadyLoadedVars() + { + unset($_ENV['FOO'], $_ENV['BAR'], $_ENV['SYMFONY_DOTENV_VARS']); + unset($_SERVER['FOO'], $_SERVER['BAR'], $_SERVER['SYMFONY_DOTENV_VARS']); + putenv('FOO'); + putenv('BAR'); + putenv('SYMFONY_DOTENV_VARS'); + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + + $path1 = tempnam($tmpdir, 'sf-'); + $path2 = tempnam($tmpdir, 'sf-'); + + file_put_contents($path1, "FOO='This\$isokay'"); + file_put_contents($path2, "BAR='hello'"); + + try { + (new Dotenv())->load($path1); + $this->assertSame('This$isokay', $_ENV['FOO']); + + (new Dotenv())->load($path2); + $this->assertSame('This$isokay', $_ENV['FOO']); + $this->assertSame('hello', $_ENV['BAR']); + } finally { + unset($_ENV['FOO'], $_ENV['BAR'], $_ENV['SYMFONY_DOTENV_VARS']); + unset($_SERVER['FOO'], $_SERVER['BAR'], $_SERVER['SYMFONY_DOTENV_VARS']); + putenv('FOO'); + putenv('BAR'); + putenv('SYMFONY_DOTENV_VARS'); + unlink($path1); + unlink($path2); + rmdir($tmpdir); + } + } + public function testLoadEnv() { $resetContext = static function (): void { @@ -586,6 +621,60 @@ public function testLoadEnvSelfReferencingVariableWithDefault() @rmdir($tmpdir); } + public function testLoadSelfReferencingVariableWithSuffix() + { + $resetContext = static function (): void { + unset($_ENV['SYMFONY_DOTENV_VARS'], $_ENV['MY_VAR']); + unset($_SERVER['SYMFONY_DOTENV_VARS'], $_SERVER['MY_VAR']); + putenv('SYMFONY_DOTENV_VARS'); + putenv('MY_VAR'); + }; + + @mkdir($tmpdir = sys_get_temp_dir().'/dotenv'); + $basePath = tempnam($tmpdir, 'sf-'); + $overridePath = tempnam($tmpdir, 'sf-'); + + // Base file sets original value, override file appends suffix + file_put_contents($basePath, 'MY_VAR=original'); + file_put_contents($overridePath, 'MY_VAR="${MY_VAR}_suffix"'); + + $resetContext(); + $dotenv = (new Dotenv())->usePutenv(); + $dotenv->load($basePath); + $dotenv->load($overridePath); + + $this->assertSame('original_suffix', getenv('MY_VAR')); + + // Test with prefix instead of suffix + file_put_contents($overridePath, 'MY_VAR="prefix_${MY_VAR}"'); + + $resetContext(); + $dotenv = (new Dotenv())->usePutenv(); + $dotenv->load($basePath); + $dotenv->load($overridePath); + + $this->assertSame('prefix_original', getenv('MY_VAR')); + + // Test chained loads (three files) + $thirdPath = tempnam($tmpdir, 'sf-'); + file_put_contents($overridePath, 'MY_VAR="${MY_VAR}_middle"'); + file_put_contents($thirdPath, 'MY_VAR="${MY_VAR}_end"'); + + $resetContext(); + $dotenv = (new Dotenv())->usePutenv(); + $dotenv->load($basePath); + $dotenv->load($overridePath); + $dotenv->load($thirdPath); + + $this->assertSame('original_middle_end', getenv('MY_VAR')); + + $resetContext(); + unlink($basePath); + unlink($overridePath); + unlink($thirdPath); + @rmdir($tmpdir); + } + public function testLoadEnvSelfReferencingEnvKeyControlsFileLoading() { $resetContext = static function (): void { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fdbf278..758fd35 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - + trigger_deprecation Doctrine\Deprecations\Deprecation::trigger Doctrine\Deprecations\Deprecation::triggerIfCalledFromOutside