From 00c3e4a8eb8841710fe0a22655bcd3dd30065ae2 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 13 Mar 2026 09:52:47 -0400 Subject: [PATCH 1/4] refactor(Storage/Local): early return standard/default path in file_exists Signed-off-by: Josh --- lib/private/Files/Storage/Local.php | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index e3afc6bd01702..167f2c6361a81 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -250,17 +250,31 @@ public function isUpdatable(string $path): bool { } public function file_exists(string $path): bool { - if ($this->caseInsensitive) { - $fullPath = $this->getSourcePath($path); - $parentPath = dirname($fullPath); - if (!is_dir($parentPath)) { - return false; - } - $content = scandir($parentPath, SCANDIR_SORT_NONE); - return is_array($content) && array_search(basename($fullPath), $content, true) !== false; - } else { - return file_exists($this->getSourcePath($path)); + $fullPath = $this->getSourcePath($path); + + // Standard (default) path + if (!$this->caseInsensitive) { + return file_exists($fullPath); } + + // Operational heuristic for case-only rename validation on + // case-insensitive filesystems (bypassed by default) - expensive! + $parentPath = dirname($fullPath); + if (!is_dir($parentPath)) { + return false; + } + + $baseName = basename($fullPath); + $content = scandir($parentPath, SCANDIR_SORT_NONE); + // When `localstorage.case_insensitive` is enabled, we intentionally do an exact + // basename lookup in the parent directory (instead of trusting file_exists() + // alone). Why: On case-insensitive filesystems, path lookup can succeed even + // if the on-disk/canonical filename casing is not what was requested. We need + // this best-effort check so case-only renames (e.g. "Foo" -> "foo") are + // reflected with the expected casing, avoiding cache/client/sync inconsistencies. + // Filesystem behavior varies (incl. Unicode normalization), so this is a + // pragmatic guard, not a strict invariant. + return is_array($content) && array_search($baseName, $content, true) !== false; } public function filemtime(string $path): int|false { From fa72d16e7b19311f718140a1eb4e3b5abeb5d811 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 13 Mar 2026 10:04:05 -0400 Subject: [PATCH 2/4] refactor(Storage/Local): move exact-case existence check for case-insensitive FS to helper Signed-off-by: Josh --- lib/private/Files/Storage/Local.php | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index 167f2c6361a81..4269d319c531f 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -257,23 +257,28 @@ public function file_exists(string $path): bool { return file_exists($fullPath); } - // Operational heuristic for case-only rename validation on - // case-insensitive filesystems (bypassed by default) - expensive! + return $this->fileExistsWithExactCase($fullPath); + } + + /** + * Best-effort exact-case existence check for case-insensitive filesystems. + */ + private function fileExistsWithExactCase(string $fullPath): bool { + // We intentionally do an exact basename lookup (instead of trusting + // file_exists() alone): on case-insensitive filesystems, path lookup can + // succeed even if canonical on-disk casing differs from the requested name. + // This best-effort guard helps ensure case-only renames (e.g. "Foo" -> "foo") + // are reflected with expected casing, reducing cache/client/sync inconsistencies. + // Filesystem behavior varies (including Unicode normalization), so this is + // pragmatic, not a strict invariant. $parentPath = dirname($fullPath); if (!is_dir($parentPath)) { return false; } $baseName = basename($fullPath); - $content = scandir($parentPath, SCANDIR_SORT_NONE); - // When `localstorage.case_insensitive` is enabled, we intentionally do an exact - // basename lookup in the parent directory (instead of trusting file_exists() - // alone). Why: On case-insensitive filesystems, path lookup can succeed even - // if the on-disk/canonical filename casing is not what was requested. We need - // this best-effort check so case-only renames (e.g. "Foo" -> "foo") are - // reflected with the expected casing, avoiding cache/client/sync inconsistencies. - // Filesystem behavior varies (incl. Unicode normalization), so this is a - // pragmatic guard, not a strict invariant. + $content = scandir($parentPath, SCANDIR_SORT_NONE); + return is_array($content) && array_search($baseName, $content, true) !== false; } From f8d1cdb50db0aec992c35aee583c3e315268474a Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 13 Mar 2026 10:44:06 -0400 Subject: [PATCH 3/4] perf(Storage/Local): avoid recursive array_merge in searchInDir() Signed-off-by: Josh --- lib/private/Files/Storage/Local.php | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index 4269d319c531f..c655333c9de81 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -456,21 +456,38 @@ public function getLocalFile(string $path): string|false { protected function searchInDir(string $query, string $dir = ''): array { $files = []; + $this->searchInDirRecursive($query, $dir, $files); + return $files; + } + + /** + * @param list $files + */ + private function searchInDirRecursive(string $query, string $dir, array &$files): void { $physicalDir = $this->getSourcePath($dir); - foreach (scandir($physicalDir) as $item) { + $items = scandir($physicalDir); + if (!is_array($items)) { + return; + } + + $queryLower = strtolower($query); + + foreach ($items as $item) { if (Filesystem::isIgnoredDir($item)) { continue; } + + $relativePath = $dir . '/' . $item; $physicalItem = $physicalDir . '/' . $item; - if (strstr(strtolower($item), strtolower($query)) !== false) { - $files[] = $dir . '/' . $item; + if (strstr(strtolower($item), $queryLower) !== false) { + $files[] = $relativePath; } + if (is_dir($physicalItem)) { - $files = array_merge($files, $this->searchInDir($query, $dir . '/' . $item)); + $this->searchInDirRecursive($query, $relativePath, $files); } } - return $files; } public function hasUpdated(string $path, int $time): bool { From 2d8d63e96d62eb1afe424421f778bf4a7dafbabb Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 13 Mar 2026 11:09:48 -0400 Subject: [PATCH 4/4] fix(Storage/Local): apply symlink policy during traversal in searchInDir Signed-off-by: Josh --- lib/private/Files/Storage/Local.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index c655333c9de81..2cacde48066dd 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -44,6 +44,8 @@ class Local extends Common { protected bool $caseInsensitive = false; + protected bool $allowSymlinks = false; + public function __construct(array $parameters) { if (!isset($parameters['datadir']) || !is_string($parameters['datadir'])) { throw new \InvalidArgumentException('No data directory set for local storage'); @@ -64,6 +66,7 @@ public function __construct(array $parameters) { $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); $this->defUMask = $this->config->getSystemValue('localstorage.umask', 0022); $this->caseInsensitive = $this->config->getSystemValueBool('localstorage.case_insensitive', false); + $this->allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false); // support Write-Once-Read-Many file systems $this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false); @@ -480,6 +483,11 @@ private function searchInDirRecursive(string $query, string $dir, array &$files) $relativePath = $dir . '/' . $item; $physicalItem = $physicalDir . '/' . $item; + // Enforce no-symlink policy during search traversal as well. + if (!$this->allowSymlinks && is_link($physicalItem)) { + continue; + } + if (strstr(strtolower($item), $queryLower) !== false) { $files[] = $relativePath; } @@ -510,8 +518,7 @@ public function getSourcePath(string $path): string { $fullPath = $this->datadir . $path; $currentPath = $path; - $allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false); - if ($allowSymlinks || $currentPath === '') { + if ($this->allowSymlinks || $currentPath === '') { return $fullPath; } $pathToResolve = $fullPath;