diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php index 6778c44e0d8d..3667d9c96430 100644 --- a/system/Config/DotEnv.php +++ b/system/Config/DotEnv.php @@ -59,15 +59,24 @@ public function parse(): ?array return null; } - // Ensure the file is readable - if (! is_readable($this->path)) { - throw new InvalidArgumentException("The .env file is not readable: {$this->path}"); + $lines = @file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + // The .env file may have been removed or replaced by a concurrent + // process between the is_file() check above and this read attempt + // (e.g. another test process renaming `.env`). A vanished file is + // treated as absent, so re-check with a fresh stat cache. + if ($lines === false) { + clearstatcache(); + + if (is_file($this->path)) { + throw new InvalidArgumentException("The .env file is not readable: {$this->path}"); + } + + return null; } $vars = []; - $lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { // Is it a comment? if (str_starts_with(trim($line), '#')) { diff --git a/tests/system/Config/DotEnvTest.php b/tests/system/Config/DotEnvTest.php index c789169b1b87..58e66d412e9e 100644 --- a/tests/system/Config/DotEnvTest.php +++ b/tests/system/Config/DotEnvTest.php @@ -143,6 +143,68 @@ public function testLoadsUnreadableFile(): void $dotenv->load(); } + /** + * Regression test: a concurrent process may remove or replace the .env + * file between the is_file() check and the read attempt. In that case the + * file must be treated as absent (parse() returns null) instead of + * throwing an exception. + */ + public function testParseReturnsNullIfFileRemovedBetweenCheckAndRead(): void + { + $scheme = 'vanishenv'; + + $wrapper = new class () { + public static bool $vanished = false; + + /** + * @var resource|null + */ + public $context; + + /** + * @return array|false + */ + public function url_stat(string $path, int $flags): array|false + { + if (self::$vanished) { + return false; + } + + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0100644, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 1, + 'atime' => 1, + 'mtime' => 1, + 'ctime' => 1, + 'blksize' => 4096, + 'blocks' => 8, + ]; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + self::$vanished = true; + + return false; + } + }; + + stream_wrapper_register($scheme, $wrapper::class, STREAM_IS_URL); + + try { + $dotenv = new DotEnv("{$scheme}://dir", '.env'); + $this->assertNull($dotenv->parse()); + } finally { + stream_wrapper_unregister($scheme); + } + } + public function testQuotedDotenvLoadsEnvironmentVars(): void { $dotenv = new DotEnv($this->fixturesFolder, 'quoted.env');