Skip to content

Commit fa8cc98

Browse files
authored
Merge pull request #63101 from nextcloud/fix/add-retry-on-realpath-cache-miss
fix(LocalStorage): Clear realpath cache on fopen failure and re-try access
2 parents 0c3fe3c + 68a88bc commit fa8cc98

2 files changed

Lines changed: 145 additions & 4 deletions

File tree

lib/private/Files/Storage/Local.php

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use OCP\Server;
2525
use OCP\Util;
2626
use Psr\Log\LoggerInterface;
27+
use function fopen;
2728

2829
/**
2930
* for local filestore, we only have to map the paths
@@ -439,18 +440,42 @@ public function copy(string $source, string $target): bool {
439440
}
440441
}
441442

443+
/** The scope this has to keep is pinned by the stale realpath cache tests in LocalTest. */
444+
private function clearRealpathCache(string $sourcePath): void {
445+
$root = rtrim($this->datadir, '/');
446+
$path = $sourcePath;
447+
while (str_starts_with($path, $root . '/')) {
448+
clearstatcache(true, $path);
449+
$parent = dirname($path);
450+
if ($parent === $path) {
451+
return;
452+
}
453+
$path = $parent;
454+
}
455+
}
456+
442457
#[\Override]
443458
public function fopen(string $path, string $mode) {
444459
$sourcePath = $this->getSourcePath($path);
445460
if (!file_exists($sourcePath) && $mode === 'r') {
446461
return false;
447462
}
448463
$oldMask = umask($this->defUMask);
449-
if (($mode === 'w' || $mode === 'w+') && $this->unlinkOnTruncate) {
450-
$this->unlink($path);
464+
try {
465+
if (($mode === 'w' || $mode === 'w+') && $this->unlinkOnTruncate) {
466+
$this->unlink($path);
467+
}
468+
$result = @fopen($sourcePath, $mode);
469+
if ($result === false) {
470+
// fopen() resolves through the realpath cache, so a false can just mean
471+
// this process still has the path cached as a file after another one
472+
// turned it into a directory, for up to realpath_cache_ttl.
473+
$this->clearRealpathCache($sourcePath);
474+
$result = @fopen($sourcePath, $mode);
475+
}
476+
} finally {
477+
umask($oldMask);
451478
}
452-
$result = @fopen($sourcePath, $mode);
453-
umask($oldMask);
454479
return $result;
455480
}
456481

tests/lib/Files/Storage/LocalTest.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)