From 7f58e2aed732bb3eba71762a756c1b85035aedd3 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Tue, 4 Aug 2026 09:48:11 +0200 Subject: [PATCH 1/3] feat(e2e-encryption): Allow cross-user key location fix Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- .../encryption/lib/Command/FixKeyLocation.php | 125 ++++++++++++++++-- .../tests/Command/FixKeyLocationTest.php | 111 ++++++++++++++-- 2 files changed, 213 insertions(+), 23 deletions(-) diff --git a/apps/encryption/lib/Command/FixKeyLocation.php b/apps/encryption/lib/Command/FixKeyLocation.php index f5709536535e8..5432e6f7ef39f 100644 --- a/apps/encryption/lib/Command/FixKeyLocation.php +++ b/apps/encryption/lib/Command/FixKeyLocation.php @@ -60,6 +60,7 @@ protected function configure(): void { ->setName('encryption:fix-key-location') ->setDescription('Fix the location of encryption keys for external storage') ->addOption('dry-run', null, InputOption::VALUE_NONE, "Only list files that require key migration, don't try to perform any migration") + ->addOption('personal', null, InputOption::VALUE_NONE, 'Also check the encrypted files in the personal space of the user and restore keys found in the key trees of other users') ->addArgument('user', InputArgument::REQUIRED, 'User id to fix the key locations for'); } @@ -108,6 +109,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + if ($input->getOption('personal')) { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $personalMountPoint = $userFolder->getMountPoint()->getMountPoint(); + foreach ($this->getAllEncryptedFiles($userFolder) as $file) { + /** @var File $file */ + // group folders, external storages and received shares are their own + // mounts and follow the system wide handling + if ($file->getMountPoint()->getMountPoint() !== $personalMountPoint) { + continue; + } + try { + $this->fixKeysForPersonalFile($user, $file, $dryRun, $output); + } catch (\Throwable $e) { + $failedPaths[] = $file->getPath(); + $this->logger->error('Failed to fix the key location of ' . $file->getPath(), [ + 'app' => 'encryption', + 'exception' => $e, + ]); + $output->writeln('Failed to process ' . $file->getPath() . ': ' . $e->getMessage() . ''); + } + } + } + if ($failedPaths !== []) { $output->writeln(''); $output->writeln('' . count($failedPaths) . ' file(s) could not be processed, see the log for details:'); @@ -120,6 +144,46 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } + /** + * A personal file is healthy when its key sits in the tree of the user at the path + * of the file. A missing key can only be restored there, the personal storage + * carries the encryption wrapper, so the file decrypts transparently once the key + * is back in place. + */ + private function fixKeysForPersonalFile(IUser $user, File $file, bool $dryRun, OutputInterface $output): void { + if ($this->hasUserKey($user, $file)) { + return; + } + if (!$this->isDataEncrypted($file)) { + if ($dryRun) { + $output->writeln('' . $file->getPath() . ' needs to be marked as not encrypted'); + } else { + $this->markAsUnEncrypted($file); + $output->writeln('' . $file->getPath() . ' marked as not encrypted'); + } + return; + } + + $targetKeyPath = $this->getUserKeyPath($user, $file); + $foundKey = $this->findKeyInUserTrees($user, $file, $targetKeyPath); + if ($dryRun) { + $output->write('' . $file->getPath() . ' needs migration'); + if ($foundKey) { + $output->writeln(', valid key found at ' . $foundKey . ''); + } else { + $output->writeln(' ❌ No key found'); + } + return; + } + $output->write('Migrating key for ' . $file->getPath() . ''); + if ($foundKey) { + $this->rootView->copy($foundKey, $targetKeyPath); + $output->writeln(' Migrated key from ' . $foundKey . ''); + } else { + $output->writeln(' ❌ No key found'); + } + } + private function fixKeysForFile(IUser $user, File $file, bool $dryRun, OutputInterface $output): void { $hasSystemKey = $this->hasSystemKey($file); $hasUserKey = $this->hasUserKey($user, $file); @@ -283,7 +347,9 @@ private function tryReadFile(File $node): bool { } $data = fread($fh, 8192); fclose($fh); - return $data !== false; + // a broken unencrypted_size of 0 makes the stream return nothing at all + // instead of failing, an empty read proves nothing about the key + return $data !== false && $data !== ''; } catch (\Exception) { return false; } @@ -333,16 +399,41 @@ private function isDataEncrypted(File $node): bool { * Attempt to find a key (stored for user) for a file (that needs a system key) even when it's not stored in the expected location */ private function findUserKeyForSystemFile(IUser $user, File $node): ?string { - $userKeyPath = $this->getUserBaseKeyPath($user); - $possibleKeys = $this->findKeysByFileName($userKeyPath, $node->getName()); - foreach ($possibleKeys as $possibleKey) { - if ($this->testSystemKey($user, $possibleKey, $node)) { - return $possibleKey; + return $this->findKeyInUserTrees($user, $node, $this->getSystemKeyPath($node)); + } + + /** + * Search the key trees of all users for a key that decrypts the file, the tree of + * the given user first. Candidates are matched by file name and validated by a + * decryption attempt with the key staged at the given path. + */ + private function findKeyInUserTrees(IUser $user, File $node, string $stageKeyPath): ?string { + foreach ($this->getUserBaseKeyPaths($user) as $basePath) { + foreach ($this->findKeysByFileName($basePath, $node->getName()) as $possibleKey) { + if ($this->testKeyAtPath($node, $possibleKey, $stageKeyPath)) { + return $possibleKey; + } } } return null; } + /** + * Base key paths of all users, the given user first. Users without a key tree are + * skipped cheaply by the key search. + * + * @return \Generator + */ + private function getUserBaseKeyPaths(IUser $firstUser): \Generator { + yield $this->getUserBaseKeyPath($firstUser); + + foreach ($this->userManager->search('') as $user) { + if ($user->getUID() !== $firstUser->getUID()) { + yield $this->keyRootDirectory . '/' . $user->getUID() . '/files_encryption/keys'; + } + } + } + /** * Attempt to find a key for a file even when it's not stored in the expected location * @@ -376,19 +467,17 @@ private function findKeysByFileName(string $basePath, string $name) { } /** - * Test if the provided key is valid as a system key for the file + * Test whether the key decrypts the file when staged at the given key path */ - private function testSystemKey(IUser $user, string $key, File $node): bool { - $systemKeyPath = $this->getSystemKeyPath($node); - - if ($this->rootView->file_exists($systemKeyPath)) { + private function testKeyAtPath(File $node, string $key, string $stageKeyPath): bool { + if ($this->rootView->file_exists($stageKeyPath)) { // already has a key, reject new key return false; } - $this->rootView->copy($key, $systemKeyPath); + $this->rootView->copy($key, $stageKeyPath); $isValid = $this->tryReadFile($node); - $this->rootView->rmdir($systemKeyPath); + $this->rootView->rmdir($stageKeyPath); return $isValid; } @@ -425,6 +514,11 @@ private function decryptWithSystemKey(File $node, string $key): void { if ($this->isDataEncrypted($decryptedNode)) { throw new \Exception($node->getPath() . ' still encrypted after attempting to decrypt with ' . $key); } + // a broken unencrypted_size of 0 makes the decryption stream produce + // nothing at all, an empty result for a non empty source is data loss + if ($decryptedNode->getSize() === 0 && $node->getSize() > 0) { + throw new \Exception($node->getPath() . ' decrypted to an empty file, refusing the result'); + } } catch (\Throwable $e) { // the target has to go first so the .bak can move back onto its name if ($decryptedNode !== null) { @@ -451,6 +545,9 @@ private function decryptWithSystemKey(File $node, string $key): void { } private function markAsUnEncrypted(Node $node): void { - $node->getStorage()->getCache()->update($node->getId(), ['encrypted' => 0]); + $node->getStorage()->getCache()->update($node->getId(), [ + 'encrypted' => 0, + 'unencrypted_size' => 0 + ]); } } diff --git a/apps/encryption/tests/Command/FixKeyLocationTest.php b/apps/encryption/tests/Command/FixKeyLocationTest.php index 3c0888c30dbe1..64970958560d9 100644 --- a/apps/encryption/tests/Command/FixKeyLocationTest.php +++ b/apps/encryption/tests/Command/FixKeyLocationTest.php @@ -87,9 +87,31 @@ private function getCommand(): TestableFixKeyLocation { ); } - private function markAsEncrypted(TemporaryUnwrapped $storage, string $path): void { + private function markAsEncrypted(TemporaryUnwrapped $storage, string $path, int $unencryptedSize): void { $cache = $storage->getCache(); - $cache->update($cache->get($path)->getId(), ['encrypted' => 1]); + $cache->update($cache->get($path)->getId(), [ + 'encrypted' => 1, + 'unencrypted_size' => $unencryptedSize, + ]); + } + + /** + * A second user whose key tree can hold misplaced keys. The key tree is created + * directly, no login needed, the encryption session of the first user stays + * untouched. + */ + private function setUpSecondUser(): void { + $this->createUser('test2', 'test2'); + } + + private function moveKeyToSecondUserTree(string $keyPath, string $name): void { + $rootView = new View(); + foreach (['/test2', '/test2/files_encryption', '/test2/files_encryption/keys'] as $dir) { + if (!$rootView->file_exists($dir)) { + $rootView->mkdir($dir); + } + } + $rootView->rename(rtrim($keyPath, '/'), '/test2/files_encryption/keys/' . $name); } /** @@ -107,7 +129,7 @@ public function testKeyValidationReadsThroughEncryption(): void { // ciphertext under a path that has no key $view->file_put_contents('stray/stray.txt', $encryptedBackingStorage->file_get_contents('original.txt')); $strayStorage = $view->getFileInfo('stray/stray.txt')->getStorage(); - $this->markAsEncrypted($strayStorage, 'stray.txt'); + $this->markAsEncrypted($strayStorage, 'stray.txt', strlen('secret content')); $userFolder = Server::get(IRootFolder::class)->getUserFolder('test1'); $command = $this->getCommand(); @@ -137,7 +159,7 @@ public function testFailedDecryptionRollsBack(): void { $cipher = $encryptedBackingStorage->file_get_contents('original.txt'); $view->file_put_contents('stray/broken.txt', $cipher); $strayStorage = $view->getFileInfo('stray/broken.txt')->getStorage(); - $this->markAsEncrypted($strayStorage, 'broken.txt'); + $this->markAsEncrypted($strayStorage, 'broken.txt', strlen('secret content')); $userFolder = Server::get(IRootFolder::class)->getUserFolder('test1'); $strayNode = $userFolder->get('stray/broken.txt'); @@ -153,11 +175,13 @@ public function testFailedDecryptionRollsBack(): void { $brokenKeyPath = self::invokePrivate($command, 'getUserKeyPath', [$user, $strayNode]); $rootView->copy($wrongKey, str_replace('broken.txt', 'broken.txt.bak', $brokenKeyPath)); + $threw = false; try { self::invokePrivate($command, 'decryptWithSystemKey', [$strayNode, $wrongKey]); - $this->fail('decrypting with the wrong key must fail'); - } catch (\Exception $e) { + } catch (\Exception) { + $threw = true; } + $this->assertTrue($threw, 'decrypting with the wrong key must fail'); $this->assertTrue($view->file_exists('stray/broken.txt'), 'the original file has to be restored'); $this->assertSame($cipher, $view->file_get_contents('stray/broken.txt'), 'the original content has to be intact'); @@ -169,6 +193,70 @@ public function testFailedDecryptionRollsBack(): void { $this->assertFalse($rootView->file_exists($systemKeyPathBak), 'no temporary system key must be left behind'); } + /** + * A key that only exists in the tree of another user must be found and validated. + * The harness mounts are not system wide, the encryption wrapper resolves the keys + * of the stray file through the user tree, so the search is exercised with that + * staging path, the system wide flow only stages at a different location. + */ + public function testKeyFoundInAnotherUsersTree(): void { + [ + 'view' => $view, + 'encryptedBackingStorage' => $encryptedBackingStorage, + ] = $this->setUpMounts(); + $this->setUpSecondUser(); + + $view->file_put_contents('enc/original.txt', 'secret content'); + $view->file_put_contents('stray/orphan.txt', $encryptedBackingStorage->file_get_contents('original.txt')); + $strayStorage = $view->getFileInfo('stray/orphan.txt')->getStorage(); + $this->markAsEncrypted($strayStorage, 'orphan.txt', strlen('secret content')); + + $command = $this->getCommand(); + $user = Server::get(IUserManager::class)->get('test1'); + $userFolder = Server::get(IRootFolder::class)->getUserFolder('test1'); + $originalKey = self::invokePrivate($command, 'getUserKeyPath', [$user, $userFolder->get('enc/original.txt')]); + // the key sits in ANOTHER user's tree, under the name of the stray file + $this->moveKeyToSecondUserTree($originalKey, 'orphan.txt'); + + $strayNode = $userFolder->get('stray/orphan.txt'); + $stagePath = self::invokePrivate($command, 'getUserKeyPath', [$user, $strayNode]); + $foundKey = self::invokePrivate($command, 'findKeyInUserTrees', [$user, $strayNode, $stagePath]); + + $this->assertNotNull($foundKey, 'the key in the other tree has to be found'); + $this->assertStringContainsString('/test2/', $foundKey); + } + + /** + * With --personal an encrypted file in the personal space whose key was lost is + * restored from another user's tree, healthy files stay untouched. + */ + public function testPersonalFileKeyFoundInAnotherUsersTree(): void { + $this->setUpMounts(); + $this->setUpSecondUser(); + + $view = new View('/test1/files'); + $view->file_put_contents('personal.txt', 'personal content'); + $view->file_put_contents('healthy.txt', 'healthy content'); + + $command = $this->getCommand(); + $user = Server::get(IUserManager::class)->get('test1'); + $userFolder = Server::get(IRootFolder::class)->getUserFolder('test1'); + $personalKey = self::invokePrivate($command, 'getUserKeyPath', [$user, $userFolder->get('personal.txt')]); + $this->moveKeyToSecondUserTree($personalKey, 'personal.txt'); + + $command->systemMounts = []; + $tester = new CommandTester($command); + $exitCode = $tester->execute(['user' => 'test1', '--personal' => true]); + $display = $tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $exitCode, $display); + $this->assertStringContainsString('Migrated key from', $display); + $this->assertEquals('personal content', $view->file_get_contents('personal.txt')); + $rootView = new View(); + $this->assertTrue($rootView->file_exists($personalKey), 'the key has to be back at the path of the file'); + $this->assertStringNotContainsString('healthy.txt', $display, 'healthy files must not be touched'); + } + /** * One broken file must not abort the whole run, the remaining files still get * processed and the failure is reported. @@ -180,17 +268,17 @@ public function testExecuteContinuesAfterFailure(): void { $view->file_put_contents('stray/a-ghost.txt', 'gone'); $strayStorage = $view->getFileInfo('stray/a-ghost.txt')->getStorage(); - $this->markAsEncrypted($strayStorage, 'a-ghost.txt'); + $this->markAsEncrypted($strayStorage, 'a-ghost.txt', strlen('gone')); // cache row without a backing file, reading it fails like an object store 404 $strayStorage->unlink('a-ghost.txt'); $view->file_put_contents('stray/b-plain.txt', 'plain data'); - $this->markAsEncrypted($strayStorage, 'b-plain.txt'); + $this->markAsEncrypted($strayStorage, 'b-plain.txt', strlen('plain data')); // ciphertext without any key while the user has no key directory at all, // the key search has to come up empty instead of erroring out $view->file_put_contents('stray/c-cipher.txt', 'HBEGIN:oc_encryption_module:OC_DEFAULT_MODULE:HEND'); - $this->markAsEncrypted($strayStorage, 'c-cipher.txt'); + $this->markAsEncrypted($strayStorage, 'c-cipher.txt', 8); $command = $this->getCommand(); $mount = $this->createMock(ICachedMountInfo::class); @@ -209,6 +297,11 @@ public function testExecuteContinuesAfterFailure(): void { $strayStorage->getCache()->get('b-plain.txt')->isEncrypted(), 'the remaining file was not processed, the run stopped at the broken one' ); + $this->assertSame( + 0, + (int)$strayStorage->getCache()->get('b-plain.txt')->getData()['unencrypted_size'], + 'clearing the mark has to reset the leftover unencrypted size as well' + ); $this->assertStringContainsString('No key found', $display, 'a missing key directory has to read as "no candidates"'); $this->assertStringNotContainsString('c-cipher.txt: ', $display, 'the missing key directory failed the file'); } From 05135a7385aedf1f06526197361025b115396395 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Wed, 5 Aug 2026 12:26:38 +0200 Subject: [PATCH 2/3] fix(e2e-encryption): Fix memory leak for large installations Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- .../encryption/lib/Command/FixKeyLocation.php | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/encryption/lib/Command/FixKeyLocation.php b/apps/encryption/lib/Command/FixKeyLocation.php index 5432e6f7ef39f..20d010e3cc9e8 100644 --- a/apps/encryption/lib/Command/FixKeyLocation.php +++ b/apps/encryption/lib/Command/FixKeyLocation.php @@ -33,6 +33,8 @@ class FixKeyLocation extends Command { private string $keyRootDirectory; private View $rootView; private Manager $encryptionManager; + /** @var list|null */ + private ?array $userKeyBasePaths = null; public function __construct( private IUserManager $userManager, @@ -419,17 +421,35 @@ private function findKeyInUserTrees(IUser $user, File $node, string $stageKeyPat } /** - * Base key paths of all users, the given user first. Users without a key tree are - * skipped cheaply by the key search. + * Base key paths of all existing user key trees, the given user first. Enumerated + * from the key storage directory instead of the user backends: the search runs for + * every file, and querying the backends pulls every user object into memory. * * @return \Generator */ private function getUserBaseKeyPaths(IUser $firstUser): \Generator { - yield $this->getUserBaseKeyPath($firstUser); - - foreach ($this->userManager->search('') as $user) { - if ($user->getUID() !== $firstUser->getUID()) { - yield $this->keyRootDirectory . '/' . $user->getUID() . '/files_encryption/keys'; + $firstUserBasePath = $this->getUserBaseKeyPath($firstUser); + yield $firstUserBasePath; + + if ($this->userKeyBasePaths === null) { + $this->userKeyBasePaths = []; + $dh = $this->rootView->opendir($this->keyRootDirectory === '' ? '/' : $this->keyRootDirectory); + if ($dh !== false) { + while (($entry = readdir($dh)) !== false) { + if ($entry === '.' || $entry === '..') { + continue; + } + $basePath = $this->keyRootDirectory . '/' . $entry . '/files_encryption/keys'; + if ($this->rootView->is_dir($basePath)) { + $this->userKeyBasePaths[] = $basePath; + } + } + closedir($dh); + } + } + foreach ($this->userKeyBasePaths as $basePath) { + if ($basePath !== $firstUserBasePath) { + yield $basePath; } } } @@ -547,7 +567,7 @@ private function decryptWithSystemKey(File $node, string $key): void { private function markAsUnEncrypted(Node $node): void { $node->getStorage()->getCache()->update($node->getId(), [ 'encrypted' => 0, - 'unencrypted_size' => 0 + 'unencrypted_size' => 0, ]); } } From 523c7176db5b09fce15e72a89f2f566cfc9d6e60 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Fri, 7 Aug 2026 15:31:19 +0200 Subject: [PATCH 3/3] fix(e2e-encryption): Search for all keys instead of only name-matching ones Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- .../encryption/lib/Command/FixKeyLocation.php | 122 ++++++++++++++---- 1 file changed, 94 insertions(+), 28 deletions(-) diff --git a/apps/encryption/lib/Command/FixKeyLocation.php b/apps/encryption/lib/Command/FixKeyLocation.php index 20d010e3cc9e8..3d18109b23213 100644 --- a/apps/encryption/lib/Command/FixKeyLocation.php +++ b/apps/encryption/lib/Command/FixKeyLocation.php @@ -35,6 +35,8 @@ class FixKeyLocation extends Command { private Manager $encryptionManager; /** @var list|null */ private ?array $userKeyBasePaths = null; + /** @var array> */ + private array $keyDirectoriesCache = []; public function __construct( private IUserManager $userManager, @@ -95,18 +97,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - $files = $this->getAllEncryptedFiles($mountRootFolder); - foreach ($files as $file) { + // collect paths first: processing must not run on a live node graph, the + // periodic filesystem reset below would pull storages out from under it + $filePaths = []; + foreach ($this->getAllEncryptedFiles($mountRootFolder) as $file) { /** @var File $file */ + $filePaths[] = $file->getPath(); + } + + foreach ($filePaths as $filePath) { try { + $this->resetFilesystemIfNeeded($user); + $file = $this->rootFolder->get($filePath); + if (!$file instanceof File) { + continue; + } $this->fixKeysForFile($user, $file, $dryRun, $output); } catch (\Throwable $e) { - $failedPaths[] = $file->getPath(); - $this->logger->error('Failed to fix the key location of ' . $file->getPath(), [ + $failedPaths[] = $filePath; + $this->logger->error('Failed to fix the key location of ' . $filePath, [ 'app' => 'encryption', 'exception' => $e, ]); - $output->writeln('Failed to process ' . $file->getPath() . ': ' . $e->getMessage() . ''); + $output->writeln('Failed to process ' . $filePath . ': ' . $e->getMessage() . ''); } } } @@ -114,6 +127,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($input->getOption('personal')) { $userFolder = $this->rootFolder->getUserFolder($user->getUID()); $personalMountPoint = $userFolder->getMountPoint()->getMountPoint(); + $filePaths = []; foreach ($this->getAllEncryptedFiles($userFolder) as $file) { /** @var File $file */ // group folders, external storages and received shares are their own @@ -121,15 +135,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($file->getMountPoint()->getMountPoint() !== $personalMountPoint) { continue; } + $filePaths[] = $file->getPath(); + } + + foreach ($filePaths as $filePath) { try { + $this->resetFilesystemIfNeeded($user); + $file = $this->rootFolder->get($filePath); + if (!$file instanceof File) { + continue; + } $this->fixKeysForPersonalFile($user, $file, $dryRun, $output); } catch (\Throwable $e) { - $failedPaths[] = $file->getPath(); - $this->logger->error('Failed to fix the key location of ' . $file->getPath(), [ + $failedPaths[] = $filePath; + $this->logger->error('Failed to fix the key location of ' . $filePath, [ 'app' => 'encryption', 'exception' => $e, ]); - $output->writeln('Failed to process ' . $file->getPath() . ': ' . $e->getMessage() . ''); + $output->writeln('Failed to process ' . $filePath . ': ' . $e->getMessage() . ''); } } } @@ -146,6 +169,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } + /** + * Accessing another user's key tree sets up that user's filesystem, and the mounts + * accumulate for every tree the key search touches. A teardown drops them all; + * only performed when memory actually grew, the string caches survive it, so no + * directory walk is repeated. + */ + private function resetFilesystemIfNeeded(IUser $user): void { + if (memory_get_usage() < 1024 * 1024 * 1024) { + return; + } + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user->getUID()); + } + /** * A personal file is healthy when its key sits in the tree of the user at the path * of the file. A missing key can only be restored there, the personal storage @@ -455,35 +492,64 @@ private function getUserBaseKeyPaths(IUser $firstUser): \Generator { } /** - * Attempt to find a key for a file even when it's not stored in the expected location + * All key directories below the base path, the ones matching the file name first: + * a matching name is the most likely key, but renames since the key was stranded + * make the name unreliable, so every other key is offered as a candidate as well. + * Validation is cryptographic, a wrong candidate cannot pass. * * @return \Generator */ private function findKeysByFileName(string $basePath, string $name) { - if (!$this->rootView->is_dir($basePath)) { - // no keys stored for the user at all + $matching = []; + $other = []; + foreach ($this->findAllKeyDirectories($basePath) as $keyDirectory) { + if (basename($keyDirectory) === $name) { + $matching[] = $keyDirectory; + } else { + $other[] = $keyDirectory; + } + } + yield from $matching; + yield from $other; + } + + /** + * @return list + */ + private function findAllKeyDirectories(string $basePath): array { + if (isset($this->keyDirectoriesCache[$basePath])) { + return $this->keyDirectoriesCache[$basePath]; + } + $keyDirectories = []; + if ($this->rootView->is_dir($basePath)) { + $this->collectKeyDirectories($basePath, $keyDirectories); + } + return $this->keyDirectoriesCache[$basePath] = $keyDirectories; + } + + /** + * @param list $keyDirectories + */ + private function collectKeyDirectories(string $path, array &$keyDirectories): void { + $dh = $this->rootView->opendir($path); + if ($dh === false) { return; } - if ($this->rootView->is_dir($basePath . '/' . $name . '/OC_DEFAULT_MODULE')) { - yield $basePath . '/' . $name; - } else { - /** @var false|resource $dh */ - $dh = $this->rootView->opendir($basePath); - if (!$dh) { - throw new \Exception('Invalid base path ' . $basePath); + while (($child = readdir($dh)) !== false) { + if ($child === '.' || $child === '..') { + continue; } - while ($child = readdir($dh)) { - if ($child != '..' && $child != '.') { - $childPath = $basePath . '/' . $child; - - // recurse if the child is not a key folder - /** @psalm-suppress InternalMethod */ - if ($this->rootView->is_dir($childPath) && !is_dir($childPath . '/OC_DEFAULT_MODULE')) { - yield from $this->findKeysByFileName($childPath, $name); - } - } + $childPath = $path . '/' . $child; + if (!$this->rootView->is_dir($childPath)) { + continue; + } + if ($this->rootView->is_dir($childPath . '/OC_DEFAULT_MODULE')) { + $keyDirectories[] = $childPath; + } else { + $this->collectKeyDirectories($childPath, $keyDirectories); } } + closedir($dh); } /**