@@ -164,4 +164,120 @@ 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+ public function testFopenRestoresUmaskWhenTheWritePathThrows (): void {
169+ $ storage = new class (['datadir ' => $ this ->tmpDir ]) extends Local {
170+ #[\Override]
171+ public function __construct (array $ parameters ) {
172+ parent ::__construct ($ parameters );
173+ $ this ->unlinkOnTruncate = true ;
174+ }
175+
176+ #[\Override]
177+ public function unlink (string $ path ): bool {
178+ throw new \RuntimeException ('unlink failed ' );
179+ }
180+ };
181+
182+ $ ambient = umask (0077 );
183+ $ thrown = false ;
184+ try {
185+ $ storage ->fopen ('target.txt ' , 'w ' );
186+ } catch (\RuntimeException ) {
187+ $ thrown = true ;
188+ }
189+ $ leaked = umask ($ ambient );
190+
191+ $ this ->assertTrue ($ thrown , 'the exception has to propagate ' );
192+ $ this ->assertSame (0077 , $ leaked , 'umask has to be restored when the write path throws ' );
193+ }
194+
195+ public function testFopenRecoveryLeavesEntriesOutsideTheDataDirectoryAlone (): void {
196+ if (!function_exists ('exec ' )) {
197+ $ this ->markTestSkipped ('exec() is required to change the path type out of process ' );
198+ }
199+
200+ $ dataDir = rtrim ($ this ->tmpDir , '/ ' );
201+ $ stalePath = $ this ->tmpDir . 'folder ' ;
202+ $ file = $ stalePath . '/a.txt ' ;
203+
204+ // opened only to get the "not a directory" entry into the realpath cache
205+ $ this ->instance ->file_put_contents ('folder ' , 'its a file ' );
206+ $ handle = $ this ->instance ->fopen ('folder ' , 'r ' );
207+ $ this ->assertIsResource ($ handle );
208+ fclose ($ handle );
209+
210+ if ((realpath_cache_get ()[$ stalePath ]['is_dir ' ] ?? null ) !== false ) {
211+ $ this ->markTestSkipped ('the realpath cache of this setup does not hold the directory flag ' );
212+ }
213+
214+ realpath (__DIR__ );
215+ realpath ($ dataDir );
216+ $ this ->assertArrayHasKey (__DIR__ , realpath_cache_get ());
217+ $ this ->assertArrayHasKey ($ dataDir , realpath_cache_get ());
218+
219+ exec (
220+ 'rm -f ' . escapeshellarg ($ stalePath )
221+ . ' && mkdir -p ' . escapeshellarg ($ stalePath )
222+ . ' && printf abc > ' . escapeshellarg ($ file ),
223+ $ output ,
224+ $ status
225+ );
226+ $ this ->assertSame (0 , $ status , 'failed to replace the file with a directory ' );
227+
228+ $ handle = $ this ->instance ->fopen ('folder/a.txt ' , 'r ' );
229+ $ this ->assertIsResource ($ handle );
230+ fclose ($ handle );
231+
232+ $ cache = realpath_cache_get ();
233+ $ this ->assertArrayHasKey (__DIR__ , $ cache , 'entries outside the data directory have to survive ' );
234+ $ this ->assertArrayHasKey ($ dataDir , $ cache , 'the walk has to stop at the data directory ' );
235+ }
236+
237+ public static function dataStaleRealpathCache (): array {
238+ return [
239+ // invalidating only the direct parent passes the first and fails the second
240+ 'stale direct parent ' => ['staleName ' => 'folder ' , 'filePath ' => 'folder/a.txt ' ],
241+ 'stale grandparent ' => ['staleName ' => 'nickname ' , 'filePath ' => 'nickname/folder/a.txt ' ],
242+ ];
243+ }
244+
245+ /**
246+ * The type change has to happen out of process: PHP drops its own realpath cache
247+ * entry when it is the one calling unlink() and mkdir(), so doing it here would
248+ * leave nothing stale and the test would pass either way.
249+ */
250+ #[\PHPUnit \Framework \Attributes \DataProvider('dataStaleRealpathCache ' )]
251+ public function testFopenRecoversFromStaleRealpathCache (string $ staleName , string $ filePath ): void {
252+ if (!function_exists ('exec ' )) {
253+ $ this ->markTestSkipped ('exec() is required to change the path type out of process ' );
254+ }
255+
256+ $ stalePath = $ this ->tmpDir . $ staleName ;
257+ $ file = $ this ->tmpDir . $ filePath ;
258+
259+ // opened only to get the "not a directory" entry into the realpath cache
260+ $ this ->instance ->file_put_contents ($ staleName , 'its a file ' );
261+ $ handle = $ this ->instance ->fopen ($ staleName , 'r ' );
262+ $ this ->assertIsResource ($ handle );
263+ fclose ($ handle );
264+
265+ if ((realpath_cache_get ()[$ stalePath ]['is_dir ' ] ?? null ) !== false ) {
266+ $ this ->markTestSkipped ('the realpath cache of this setup does not hold the directory flag ' );
267+ }
268+
269+ exec (
270+ 'rm -f ' . escapeshellarg ($ stalePath )
271+ . ' && mkdir -p ' . escapeshellarg (dirname ($ file ))
272+ . ' && printf abc > ' . escapeshellarg ($ file ),
273+ $ output ,
274+ $ status
275+ );
276+ $ this ->assertSame (0 , $ status , 'failed to replace the file with a directory ' );
277+
278+ $ handle = $ this ->instance ->fopen ($ filePath , 'r ' );
279+ $ this ->assertIsResource ($ handle , 'fopen() has to recover from the stale realpath cache entry ' );
280+ $ this ->assertSame ('abc ' , stream_get_contents ($ handle ));
281+ fclose ($ handle );
282+ }
167283}
0 commit comments