@@ -164,4 +164,51 @@ public function testMoveNestedJail(): void {
164164 $ jail3 ->moveFromStorage ($ jail2 , 'file.txt ' , 'file.txt ' );
165165 $ this ->assertTrue ($ this ->instance ->file_exists ('target/file.txt ' ));
166166 }
167+
168+ /**
169+ * The type change has to happen out of process: PHP drops its own realpath cache
170+ * entry when it is the one calling unlink() and mkdir(), so doing it here would
171+ * leave nothing stale and the test would pass either way.
172+ */
173+ public static function dataStaleRealpathCache (): array {
174+ return [
175+ // invalidating only the direct parent passes the first and fails the second
176+ 'stale direct parent ' => ['staleName ' => 'folder ' , 'filePath ' => 'folder/a.txt ' ],
177+ 'stale grandparent ' => ['staleName ' => 'nickname ' , 'filePath ' => 'nickname/folder/a.txt ' ],
178+ ];
179+ }
180+
181+ #[\PHPUnit \Framework \Attributes \DataProvider('dataStaleRealpathCache ' )]
182+ public function testFopenRecoversFromStaleRealpathCache (string $ staleName , string $ filePath ): void {
183+ if (!function_exists ('exec ' )) {
184+ $ this ->markTestSkipped ('exec() is required to change the path type out of process ' );
185+ }
186+
187+ $ stalePath = $ this ->tmpDir . $ staleName ;
188+ $ file = $ this ->tmpDir . $ filePath ;
189+
190+ // opened only to get the "not a directory" entry into the realpath cache
191+ $ this ->instance ->file_put_contents ($ staleName , 'its a file ' );
192+ $ handle = $ this ->instance ->fopen ($ staleName , 'r ' );
193+ $ this ->assertIsResource ($ handle );
194+ fclose ($ handle );
195+
196+ if ((realpath_cache_get ()[$ stalePath ]['is_dir ' ] ?? null ) !== false ) {
197+ $ this ->markTestSkipped ('the realpath cache of this setup does not hold the directory flag ' );
198+ }
199+
200+ exec (
201+ 'rm -f ' . escapeshellarg ($ stalePath )
202+ . ' && mkdir -p ' . escapeshellarg (dirname ($ file ))
203+ . ' && printf abc > ' . escapeshellarg ($ file ),
204+ $ output ,
205+ $ status
206+ );
207+ $ this ->assertSame (0 , $ status , 'failed to replace the file with a directory ' );
208+
209+ $ handle = $ this ->instance ->fopen ($ filePath , 'r ' );
210+ $ this ->assertIsResource ($ handle , 'fopen() has to recover from the stale realpath cache entry ' );
211+ $ this ->assertSame ('abc ' , stream_get_contents ($ handle ));
212+ fclose ($ handle );
213+ }
167214}
0 commit comments