From 148c23acd389c230fb6ddb9d169fe6800d977bf4 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 31 Jul 2026 21:37:12 +0200 Subject: [PATCH] fix: treat a vanished .env file as absent instead of throwing The .env file may be removed or replaced by a concurrent process between the is_file() check and the read attempt, e.g. another test process renaming ROOTPATH/.env. The previous is_readable() pre-check performed a fresh access() syscall that could fail on a just-renamed file, crashing CI with 'The .env file is not readable'. Read the file first and only throw when it still exists after a re-check with a fresh stat cache. --- system/Config/DotEnv.php | 19 ++++++--- tests/system/Config/DotEnvTest.php | 62 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) 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');