Skip to content

Commit c1ee0ec

Browse files
committed
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.
1 parent f3829fe commit c1ee0ec

2 files changed

Lines changed: 76 additions & 5 deletions

File tree

system/Config/DotEnv.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,24 @@ public function parse(): ?array
5959
return null;
6060
}
6161

62-
// Ensure the file is readable
63-
if (! is_readable($this->path)) {
64-
throw new InvalidArgumentException("The .env file is not readable: {$this->path}");
62+
$lines = @file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
63+
64+
// The .env file may have been removed or replaced by a concurrent
65+
// process between the is_file() check above and this read attempt
66+
// (e.g. another test process renaming `.env`). A vanished file is
67+
// treated as absent, so re-check with a fresh stat cache.
68+
if ($lines === false) {
69+
clearstatcache();
70+
71+
if (is_file($this->path)) {
72+
throw new InvalidArgumentException("The .env file is not readable: {$this->path}");
73+
}
74+
75+
return null;
6576
}
6677

6778
$vars = [];
6879

69-
$lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
70-
7180
foreach ($lines as $line) {
7281
// Is it a comment?
7382
if (str_starts_with(trim($line), '#')) {

tests/system/Config/DotEnvTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,68 @@ public function testLoadsUnreadableFile(): void
143143
$dotenv->load();
144144
}
145145

146+
/**
147+
* Regression test: a concurrent process may remove or replace the .env
148+
* file between the is_file() check and the read attempt. In that case the
149+
* file must be treated as absent (parse() returns null) instead of
150+
* throwing an exception.
151+
*/
152+
public function testParseReturnsNullIfFileRemovedBetweenCheckAndRead(): void
153+
{
154+
$scheme = 'vanishenv';
155+
156+
$wrapper = new class {
157+
public static bool $vanished = false;
158+
159+
/**
160+
* @var resource|null
161+
*/
162+
public $context;
163+
164+
/**
165+
* @return array<string, int>|false
166+
*/
167+
public function url_stat(string $path, int $flags): array|false
168+
{
169+
if (self::$vanished) {
170+
return false;
171+
}
172+
173+
return [
174+
'dev' => 0,
175+
'ino' => 0,
176+
'mode' => 0100644,
177+
'nlink' => 1,
178+
'uid' => 0,
179+
'gid' => 0,
180+
'rdev' => 0,
181+
'size' => 1,
182+
'atime' => 1,
183+
'mtime' => 1,
184+
'ctime' => 1,
185+
'blksize' => 4096,
186+
'blocks' => 8,
187+
];
188+
}
189+
190+
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
191+
{
192+
self::$vanished = true;
193+
194+
return false;
195+
}
196+
};
197+
198+
stream_wrapper_register($scheme, $wrapper::class, STREAM_IS_URL);
199+
200+
try {
201+
$dotenv = new DotEnv("{$scheme}://dir", '.env');
202+
$this->assertNull($dotenv->parse());
203+
} finally {
204+
stream_wrapper_unregister($scheme);
205+
}
206+
}
207+
146208
public function testQuotedDotenvLoadsEnvironmentVars(): void
147209
{
148210
$dotenv = new DotEnv($this->fixturesFolder, 'quoted.env');

0 commit comments

Comments
 (0)