From ad4aaf27427072be958ac7d6a6fc670a64c60e9d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:47:11 -0300 Subject: [PATCH 01/18] test: cover upgrade-safe autoloader regression Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Bootstrap/UpgradeSafeAutoloaderTest.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php diff --git a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php new file mode 100644 index 0000000000..797402fd18 --- /dev/null +++ b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php @@ -0,0 +1,125 @@ + Date: Mon, 13 Jul 2026 10:47:16 -0300 Subject: [PATCH 02/18] fix: add upgrade-safe autoloader fallback Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Bootstrap/UpgradeSafeAutoloader.php | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 lib/Bootstrap/UpgradeSafeAutoloader.php diff --git a/lib/Bootstrap/UpgradeSafeAutoloader.php b/lib/Bootstrap/UpgradeSafeAutoloader.php new file mode 100644 index 0000000000..5c35de6cf0 --- /dev/null +++ b/lib/Bootstrap/UpgradeSafeAutoloader.php @@ -0,0 +1,71 @@ + + */ + private static array $registeredRoots = []; + + private function __construct() { + } + + public static function register(string $appRoot): void { + $normalizedRoot = rtrim($appRoot, DIRECTORY_SEPARATOR); + if ($normalizedRoot === '' || isset(self::$registeredRoots[$normalizedRoot])) { + return; + } + + spl_autoload_register( + static function (string $class) use ($normalizedRoot): void { + $file = self::resolveFile($normalizedRoot, $class); + if ($file !== null) { + require_once $file; + } + }, + true, + true, + ); + + self::$registeredRoots[$normalizedRoot] = true; + } + + private static function resolveFile(string $appRoot, string $class): ?string { + if (!str_starts_with($class, self::APP_NAMESPACE) || self::isExcludedNamespace($class)) { + return null; + } + + $relativeClass = substr($class, strlen(self::APP_NAMESPACE)); + $path = $appRoot . '/lib/' . str_replace('\\', '/', $relativeClass) . '.php'; + + if (!is_file($path)) { + return null; + } + + return $path; + } + + private static function isExcludedNamespace(string $class): bool { + foreach (self::EXCLUDED_PREFIXES as $prefix) { + if (str_starts_with($class, $prefix)) { + return true; + } + } + + return false; + } +} From 6db49a18e5df011b732e056f3ae8394f1d246fe8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:47:22 -0300 Subject: [PATCH 03/18] fix: register upgrade-safe autoloader during bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- composer/autoload.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer/autoload.php b/composer/autoload.php index 0a0d644ee3..25afb4733f 100644 --- a/composer/autoload.php +++ b/composer/autoload.php @@ -6,4 +6,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -require_once __DIR__ . '/../vendor/autoload.php'; +$loader = require __DIR__ . '/../vendor/autoload.php'; + +require_once __DIR__ . '/../lib/Bootstrap/UpgradeSafeAutoloader.php'; + +\OCA\Libresign\Bootstrap\UpgradeSafeAutoloader::register(dirname(__DIR__)); + +return $loader; From 71ddabaecb2366a5dbe32350821b902c601207ea Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:00:44 -0300 Subject: [PATCH 04/18] test: align upgrade-safe regression with composer classmap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Bootstrap/UpgradeSafeAutoloaderTest.php | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php index 797402fd18..10134dc136 100644 --- a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php +++ b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php @@ -13,14 +13,14 @@ final class UpgradeSafeAutoloaderTest extends TestCase { private const MIGRATION_CLASS = 'OCA\\Libresign\\Migration\\Version99999Date20260713000000'; - private const TEST_CLASS = 'OCA\\Libresign\\Tests\\Unit\\Bootstrap\\GeneratedExcludedClass'; - private const VENDOR_CLASS = 'OCA\\Libresign\\Vendor\\GeneratedExcludedClass'; + private const NON_MIGRATION_CLASS = 'OCA\\Libresign\\Service\\GeneratedNonMigrationClass'; /** * @runInSeparateProcess */ public function testLoadsMigrationClassAfterComposerCachedPreviousMiss(): void { $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); + $loader = require __DIR__ . '/../../../../vendor/autoload.php'; try { mkdir($appRoot . '/lib/Migration', 0755, true); @@ -41,7 +41,7 @@ final class Version99999Date20260713000000 { PHP, ); - UpgradeSafeAutoloader::register($appRoot); + UpgradeSafeAutoloader::register($loader, $appRoot); self::assertTrue(class_exists(self::MIGRATION_CLASS)); } finally { @@ -52,45 +52,30 @@ final class Version99999Date20260713000000 { /** * @runInSeparateProcess */ - public function testSkipsVendorAndTestsNamespaces(): void { + public function testRegistersOnlyMigrationClasses(): void { $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); + $loader = require __DIR__ . '/../../../../vendor/autoload.php'; try { - mkdir($appRoot . '/lib/Tests/Unit/Bootstrap', 0755, true); - mkdir($appRoot . '/lib/Vendor', 0755, true); + mkdir($appRoot . '/lib/Service', 0755, true); file_put_contents( - $appRoot . '/lib/Tests/Unit/Bootstrap/GeneratedExcludedClass.php', + $appRoot . '/lib/Service/GeneratedNonMigrationClass.php', <<<'PHP' Date: Mon, 13 Jul 2026 11:00:49 -0300 Subject: [PATCH 05/18] refactor: refresh migration classmap on upgrade Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Bootstrap/UpgradeSafeAutoloader.php | 56 +++++++++---------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/lib/Bootstrap/UpgradeSafeAutoloader.php b/lib/Bootstrap/UpgradeSafeAutoloader.php index 5c35de6cf0..571680ead8 100644 --- a/lib/Bootstrap/UpgradeSafeAutoloader.php +++ b/lib/Bootstrap/UpgradeSafeAutoloader.php @@ -8,13 +8,11 @@ namespace OCA\Libresign\Bootstrap; +use Composer\Autoload\ClassLoader; + final class UpgradeSafeAutoloader { - private const APP_NAMESPACE = 'OCA\\Libresign\\'; - private const EXCLUDED_PREFIXES = [ - self::APP_NAMESPACE . '3rdparty\\', - self::APP_NAMESPACE . 'Tests\\', - self::APP_NAMESPACE . 'Vendor\\', - ]; + private const MIGRATION_NAMESPACE = 'OCA\\Libresign\\Migration\\'; + private const MIGRATION_GLOB = '/lib/Migration/Version*.php'; /** * @var array @@ -24,48 +22,34 @@ final class UpgradeSafeAutoloader { private function __construct() { } - public static function register(string $appRoot): void { + public static function register(ClassLoader $loader, string $appRoot): void { $normalizedRoot = rtrim($appRoot, DIRECTORY_SEPARATOR); if ($normalizedRoot === '' || isset(self::$registeredRoots[$normalizedRoot])) { return; } - spl_autoload_register( - static function (string $class) use ($normalizedRoot): void { - $file = self::resolveFile($normalizedRoot, $class); - if ($file !== null) { - require_once $file; - } - }, - true, - true, - ); + $classMap = self::buildMigrationClassMap($normalizedRoot); + if ($classMap !== []) { + $loader->addClassMap($classMap); + } self::$registeredRoots[$normalizedRoot] = true; } - private static function resolveFile(string $appRoot, string $class): ?string { - if (!str_starts_with($class, self::APP_NAMESPACE) || self::isExcludedNamespace($class)) { - return null; - } - - $relativeClass = substr($class, strlen(self::APP_NAMESPACE)); - $path = $appRoot . '/lib/' . str_replace('\\', '/', $relativeClass) . '.php'; - - if (!is_file($path)) { - return null; + /** + * @return array + */ + private static function buildMigrationClassMap(string $appRoot): array { + $migrationFiles = glob($appRoot . self::MIGRATION_GLOB); + if (!is_array($migrationFiles)) { + return []; } - return $path; - } - - private static function isExcludedNamespace(string $class): bool { - foreach (self::EXCLUDED_PREFIXES as $prefix) { - if (str_starts_with($class, $prefix)) { - return true; - } + $classMap = []; + foreach ($migrationFiles as $migrationFile) { + $classMap[self::MIGRATION_NAMESPACE . basename($migrationFile, '.php')] = $migrationFile; } - return false; + return $classMap; } } From d1d9050d76230caaa41e795380ebd3627aa2d35c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:00:55 -0300 Subject: [PATCH 06/18] fix: register migration classmap refresh during bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- composer/autoload.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer/autoload.php b/composer/autoload.php index 25afb4733f..f7a4554793 100644 --- a/composer/autoload.php +++ b/composer/autoload.php @@ -10,6 +10,6 @@ require_once __DIR__ . '/../lib/Bootstrap/UpgradeSafeAutoloader.php'; -\OCA\Libresign\Bootstrap\UpgradeSafeAutoloader::register(dirname(__DIR__)); +\OCA\Libresign\Bootstrap\UpgradeSafeAutoloader::register($loader, dirname(__DIR__)); return $loader; From f32e5b53965b0786344c6e11b3978d959602d705 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:04:36 -0300 Subject: [PATCH 07/18] test: cover registered loader migration refresh Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Bootstrap/UpgradeSafeAutoloaderTest.php | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php index 10134dc136..a9b911886a 100644 --- a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php +++ b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php @@ -8,19 +8,23 @@ namespace OCA\Libresign\Tests\Unit\Bootstrap; +use Composer\Autoload\ClassLoader; use OCA\Libresign\Bootstrap\UpgradeSafeAutoloader; use PHPUnit\Framework\TestCase; final class UpgradeSafeAutoloaderTest extends TestCase { private const MIGRATION_CLASS = 'OCA\\Libresign\\Migration\\Version99999Date20260713000000'; private const NON_MIGRATION_CLASS = 'OCA\\Libresign\\Service\\GeneratedNonMigrationClass'; + private const MIGRATION_FILE = '/lib/Migration/Version99999Date20260713000000.php'; /** * @runInSeparateProcess */ public function testLoadsMigrationClassAfterComposerCachedPreviousMiss(): void { $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); - $loader = require __DIR__ . '/../../../../vendor/autoload.php'; + $loader = new ClassLoader($appRoot . '/vendor'); + $loader->addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true); + $loader->register(true); try { mkdir($appRoot . '/lib/Migration', 0755, true); @@ -28,7 +32,7 @@ public function testLoadsMigrationClassAfterComposerCachedPreviousMiss(): void { self::assertFalse(class_exists(self::MIGRATION_CLASS)); file_put_contents( - $appRoot . '/lib/Migration/Version99999Date20260713000000.php', + $appRoot . self::MIGRATION_FILE, <<<'PHP' unregister(); + self::removeDirectoryRecursively($appRoot); + } + } + + /** + * @runInSeparateProcess + */ + public function testFindsRegisteredLoaderForCurrentAppRoot(): void { + $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); + $loader = new ClassLoader($appRoot . '/vendor'); + $loader->addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true); + $loader->register(true); + + try { + mkdir($appRoot . '/lib/Migration', 0755, true); + + self::assertFalse(class_exists(self::MIGRATION_CLASS)); + + file_put_contents( + $appRoot . self::MIGRATION_FILE, + <<<'PHP' +unregister(); self::removeDirectoryRecursively($appRoot); } } @@ -54,7 +96,8 @@ final class Version99999Date20260713000000 { */ public function testRegistersOnlyMigrationClasses(): void { $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); - $loader = require __DIR__ . '/../../../../vendor/autoload.php'; + $loader = new ClassLoader($appRoot . '/vendor'); + $loader->register(true); try { mkdir($appRoot . '/lib/Service', 0755, true); @@ -77,6 +120,7 @@ final class GeneratedNonMigrationClass { self::assertFalse(class_exists(self::NON_MIGRATION_CLASS)); } finally { + $loader->unregister(); self::removeDirectoryRecursively($appRoot); } } From d4abaa6d84bc515c9fc77bcf8144dd204b456851 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:04:41 -0300 Subject: [PATCH 08/18] refactor: resolve current app loader for migrations Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Bootstrap/UpgradeSafeAutoloader.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/Bootstrap/UpgradeSafeAutoloader.php b/lib/Bootstrap/UpgradeSafeAutoloader.php index 571680ead8..a3b9888cac 100644 --- a/lib/Bootstrap/UpgradeSafeAutoloader.php +++ b/lib/Bootstrap/UpgradeSafeAutoloader.php @@ -36,6 +36,15 @@ public static function register(ClassLoader $loader, string $appRoot): void { self::$registeredRoots[$normalizedRoot] = true; } + public static function registerCurrentAppLoader(string $appRoot): void { + $loader = self::findRegisteredLoader(rtrim($appRoot, DIRECTORY_SEPARATOR)); + if (!$loader instanceof ClassLoader) { + return; + } + + self::register($loader, $appRoot); + } + /** * @return array */ @@ -52,4 +61,10 @@ private static function buildMigrationClassMap(string $appRoot): array { return $classMap; } + + private static function findRegisteredLoader(string $appRoot): ?ClassLoader { + $vendorDir = $appRoot . '/vendor'; + + return ClassLoader::getRegisteredLoaders()[$vendorDir] ?? null; + } } From 4123d18e06f5038b79ae165aaf35a9d7572ac961 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:04:49 -0300 Subject: [PATCH 09/18] fix: refresh migration classmap before upgrade Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Migration/RefreshMigrationClassMap.php | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 lib/Migration/RefreshMigrationClassMap.php diff --git a/lib/Migration/RefreshMigrationClassMap.php b/lib/Migration/RefreshMigrationClassMap.php new file mode 100644 index 0000000000..e808da9a8a --- /dev/null +++ b/lib/Migration/RefreshMigrationClassMap.php @@ -0,0 +1,25 @@ + Date: Mon, 13 Jul 2026 12:04:56 -0300 Subject: [PATCH 10/18] fix: run migration classmap refresh as pre-step Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- appinfo/info.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/appinfo/info.xml b/appinfo/info.xml index 7617ab4293..f9614c25fc 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -63,6 +63,9 @@ https://libresign.coop OCA\Libresign\BackgroundJob\SignSingleFileJob + + OCA\Libresign\Migration\RefreshMigrationClassMap + OCA\Libresign\Migration\DeleteOldBinaries OCA\Libresign\Migration\ResynchronizeDatabaseSequences From 1ab082d73074de537bde33d2f5cc606bc0c88a2c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:05:02 -0300 Subject: [PATCH 11/18] refactor: restore composer autoload entrypoint Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- composer/autoload.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/composer/autoload.php b/composer/autoload.php index f7a4554793..0a0d644ee3 100644 --- a/composer/autoload.php +++ b/composer/autoload.php @@ -6,10 +6,4 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -$loader = require __DIR__ . '/../vendor/autoload.php'; - -require_once __DIR__ . '/../lib/Bootstrap/UpgradeSafeAutoloader.php'; - -\OCA\Libresign\Bootstrap\UpgradeSafeAutoloader::register($loader, dirname(__DIR__)); - -return $loader; +require_once __DIR__ . '/../vendor/autoload.php'; From 7194bfa7b7e53a2ef856d4c0aee6fd08efd95c0d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:29:51 -0300 Subject: [PATCH 12/18] test: cover migration classmap refresh step Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RefreshMigrationClassMapTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/php/Unit/Migration/RefreshMigrationClassMapTest.php diff --git a/tests/php/Unit/Migration/RefreshMigrationClassMapTest.php b/tests/php/Unit/Migration/RefreshMigrationClassMapTest.php new file mode 100644 index 0000000000..dd63b9b906 --- /dev/null +++ b/tests/php/Unit/Migration/RefreshMigrationClassMapTest.php @@ -0,0 +1,59 @@ +getName()); + } + + /** + * @runInSeparateProcess + */ + public function testRunRefreshesCurrentAppLoaderForNewMigrationFile(): void { + $appRoot = dirname(__DIR__, 4); + $migrationFile = $appRoot . self::MIGRATION_FILE; + @unlink($migrationFile); + + try { + clearstatcache(true, $migrationFile); + self::assertFalse(class_exists(self::MIGRATION_CLASS)); + + self::assertNotFalse(file_put_contents( + $migrationFile, + <<<'PHP' + run($this->createMock(IOutput::class)); + + self::assertTrue(class_exists(self::MIGRATION_CLASS)); + } finally { + @unlink($migrationFile); + } + } +} From 68741ad801f5debfcc4ac9d2fa7215a3d19932b4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:29:58 -0300 Subject: [PATCH 13/18] docs: explain migration classmap refresh step Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Migration/RefreshMigrationClassMap.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/Migration/RefreshMigrationClassMap.php b/lib/Migration/RefreshMigrationClassMap.php index e808da9a8a..0f8185ee63 100644 --- a/lib/Migration/RefreshMigrationClassMap.php +++ b/lib/Migration/RefreshMigrationClassMap.php @@ -12,6 +12,25 @@ use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; +/** + * Refresh the Composer class map for migrations immediately before Nextcloud executes them. + * + * Why this exists: + * - Nextcloud can load enabled apps earlier in the same PHP process, especially during OCC bootstrap. + * - When an update later drops a new `Version*.php` migration into place, Composer can keep a + * previous class miss cached and `MigrationService` reports `Migration step ... is unknown`. + * - Keeping this logic in a `pre-migration` repair step is more reliable than relying on + * `composer/autoload.php`, because `registerAutoloading()` uses `require_once` and that entrypoint + * may already have been included before `AppManager::upgradeApp()` reaches the migration step. + * + * References: + * - LibreSign/libresign#7892 + * - nextcloud/server#13547 + * - nextcloud/calendar_resource_management#238 + * + * If this step is ever removed or changed, reproduce those upgrade scenarios first and confirm that + * new LibreSign migrations still load correctly in both web- and OCC-driven upgrades. + */ final class RefreshMigrationClassMap implements IRepairStep { #[\Override] public function getName(): string { From 36a6511fcc7ca83bf912c26a8af2619378d6f89c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:35:41 -0300 Subject: [PATCH 14/18] test: reproduce stale require-once autoload path Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Bootstrap/UpgradeSafeAutoloaderTest.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php index a9b911886a..8ebc2d280e 100644 --- a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php +++ b/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; final class UpgradeSafeAutoloaderTest extends TestCase { + private const AUTOLOAD_COUNTER_KEY = 'libresign_upgrade_safe_autoloader_boot_count'; private const MIGRATION_CLASS = 'OCA\\Libresign\\Migration\\Version99999Date20260713000000'; private const NON_MIGRATION_CLASS = 'OCA\\Libresign\\Service\\GeneratedNonMigrationClass'; private const MIGRATION_FILE = '/lib/Migration/Version99999Date20260713000000.php'; @@ -54,6 +55,78 @@ final class Version99999Date20260713000000 { } } + /** + * @runInSeparateProcess + */ + public function testRequireOnceDoesNotReloadComposerAutoloadAfterMigrationFileAppears(): void { + $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); + unset($GLOBALS[self::AUTOLOAD_COUNTER_KEY]); + + try { + mkdir($appRoot . '/composer', 0755, true); + mkdir($appRoot . '/vendor', 0755, true); + mkdir($appRoot . '/lib/Migration', 0755, true); + + file_put_contents( + $appRoot . '/vendor/autoload.php', + "addPsr4('OCA\\\\Libresign\\\\', [dirname(__DIR__) . '/lib'], true);\n" + . " \$loader->register(true);\n" + . "}\n" + . "return \$loader;\n", + ); + + file_put_contents( + $appRoot . '/composer/autoload.php', + "unregister(); + } + unset($GLOBALS[self::AUTOLOAD_COUNTER_KEY]); + self::removeDirectoryRecursively($appRoot); + } + } + /** * @runInSeparateProcess */ From dede19596de68fe6364da7971fa2d4ef6ee9d78d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:35:45 -0300 Subject: [PATCH 15/18] docs: narrow migration refresh rationale Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Migration/RefreshMigrationClassMap.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Migration/RefreshMigrationClassMap.php b/lib/Migration/RefreshMigrationClassMap.php index 0f8185ee63..6dc1dac978 100644 --- a/lib/Migration/RefreshMigrationClassMap.php +++ b/lib/Migration/RefreshMigrationClassMap.php @@ -17,15 +17,16 @@ * * Why this exists: * - Nextcloud can load enabled apps earlier in the same PHP process, especially during OCC bootstrap. + * We verified this through the CLI path `console.php` -> `OC::init()` -> `AppManager::loadApps()`. * - When an update later drops a new `Version*.php` migration into place, Composer can keep a * previous class miss cached and `MigrationService` reports `Migration step ... is unknown`. * - Keeping this logic in a `pre-migration` repair step is more reliable than relying on - * `composer/autoload.php`, because `registerAutoloading()` uses `require_once` and that entrypoint - * may already have been included before `AppManager::upgradeApp()` reaches the migration step. + * `composer/autoload.php`, because `OC_App::registerAutoloading()` uses `require_once` and that + * entrypoint may already have been included before `Installer::updateAppstoreApp()` hands control + * to `AppManager::upgradeApp()` for the real migration execution. * * References: * - LibreSign/libresign#7892 - * - nextcloud/server#13547 * - nextcloud/calendar_resource_management#238 * * If this step is ever removed or changed, reproduce those upgrade scenarios first and confirm that From 139af64af613b620485db627d52adf2c8eecf555 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:35:26 -0300 Subject: [PATCH 16/18] refactor: move upgrade autoloader into migration Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- appinfo/info.xml | 2 +- lib/Bootstrap/UpgradeSafeAutoloader.php | 70 ------- .../RefreshAutoloaderBeforeMigrations.php | 52 +++++ lib/Migration/RefreshMigrationClassMap.php | 45 ---- lib/Migration/UpgradeSafeAutoloader.php | 197 ++++++++++++++++++ 5 files changed, 250 insertions(+), 116 deletions(-) delete mode 100644 lib/Bootstrap/UpgradeSafeAutoloader.php create mode 100644 lib/Migration/RefreshAutoloaderBeforeMigrations.php delete mode 100644 lib/Migration/RefreshMigrationClassMap.php create mode 100644 lib/Migration/UpgradeSafeAutoloader.php diff --git a/appinfo/info.xml b/appinfo/info.xml index f9614c25fc..eca901211f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -64,7 +64,7 @@ https://libresign.coop - OCA\Libresign\Migration\RefreshMigrationClassMap + OCA\Libresign\Migration\RefreshAutoloaderBeforeMigrations OCA\Libresign\Migration\DeleteOldBinaries diff --git a/lib/Bootstrap/UpgradeSafeAutoloader.php b/lib/Bootstrap/UpgradeSafeAutoloader.php deleted file mode 100644 index a3b9888cac..0000000000 --- a/lib/Bootstrap/UpgradeSafeAutoloader.php +++ /dev/null @@ -1,70 +0,0 @@ - - */ - private static array $registeredRoots = []; - - private function __construct() { - } - - public static function register(ClassLoader $loader, string $appRoot): void { - $normalizedRoot = rtrim($appRoot, DIRECTORY_SEPARATOR); - if ($normalizedRoot === '' || isset(self::$registeredRoots[$normalizedRoot])) { - return; - } - - $classMap = self::buildMigrationClassMap($normalizedRoot); - if ($classMap !== []) { - $loader->addClassMap($classMap); - } - - self::$registeredRoots[$normalizedRoot] = true; - } - - public static function registerCurrentAppLoader(string $appRoot): void { - $loader = self::findRegisteredLoader(rtrim($appRoot, DIRECTORY_SEPARATOR)); - if (!$loader instanceof ClassLoader) { - return; - } - - self::register($loader, $appRoot); - } - - /** - * @return array - */ - private static function buildMigrationClassMap(string $appRoot): array { - $migrationFiles = glob($appRoot . self::MIGRATION_GLOB); - if (!is_array($migrationFiles)) { - return []; - } - - $classMap = []; - foreach ($migrationFiles as $migrationFile) { - $classMap[self::MIGRATION_NAMESPACE . basename($migrationFile, '.php')] = $migrationFile; - } - - return $classMap; - } - - private static function findRegisteredLoader(string $appRoot): ?ClassLoader { - $vendorDir = $appRoot . '/vendor'; - - return ClassLoader::getRegisteredLoaders()[$vendorDir] ?? null; - } -} diff --git a/lib/Migration/RefreshAutoloaderBeforeMigrations.php b/lib/Migration/RefreshAutoloaderBeforeMigrations.php new file mode 100644 index 0000000000..396e1a8390 --- /dev/null +++ b/lib/Migration/RefreshAutoloaderBeforeMigrations.php @@ -0,0 +1,52 @@ + `OC::init()` -> `AppManager::loadApps()`. + * - When an update later drops new LibreSign classes into place, the original Composer loader can + * keep previous class misses cached. In the bug that triggered this step, `MigrationService` + * reported `Migration step ... is unknown` for a newly introduced migration class. + * - Requiring `composer/autoload.php` again is not enough in this flow: `OC_App::registerAutoloading()` + * uses `require_once`, and Composer keeps the app-specific loader in static state after the first + * bootstrap. We therefore rebuild a fresh loader from Composer's generated metadata files. + * - This is the safest app-level mitigation we found, not a full hot-reload primitive: PHP still + * cannot redefine classes or functions that were already loaded earlier in the same request. A + * complete fix for that broader limitation would need to happen in Nextcloud core by upgrading the + * app before it is booted, or by performing the upgrade in a fresh PHP process. + * - Keeping this logic in a `pre-migration` repair step is still the earliest reliable app-level + * extension point in `Installer::updateAppstoreApp()` -> `AppManager::upgradeApp()`. + * + * References: + * - LibreSign/libresign#7892 + * - Related but different root cause: nextcloud/calendar_resource_management#238 / #239 fixed an + * OCC upgrade by removing `classmap-authoritative`; LibreSign's main app autoloader does not + * enable that flag. + * + * If this step is ever removed or changed, reproduce those upgrade scenarios first and confirm that + * newly introduced LibreSign classes still load correctly in both web- and OCC-driven upgrades. + */ +final class RefreshAutoloaderBeforeMigrations implements IRepairStep { + #[\Override] + public function getName(): string { + return 'Refresh LibreSign autoloader before migrations'; + } + + #[\Override] + public function run(IOutput $output): void { + UpgradeSafeAutoloader::registerCurrentAppLoader(dirname(__DIR__, 2)); + } +} diff --git a/lib/Migration/RefreshMigrationClassMap.php b/lib/Migration/RefreshMigrationClassMap.php deleted file mode 100644 index 6dc1dac978..0000000000 --- a/lib/Migration/RefreshMigrationClassMap.php +++ /dev/null @@ -1,45 +0,0 @@ - `OC::init()` -> `AppManager::loadApps()`. - * - When an update later drops a new `Version*.php` migration into place, Composer can keep a - * previous class miss cached and `MigrationService` reports `Migration step ... is unknown`. - * - Keeping this logic in a `pre-migration` repair step is more reliable than relying on - * `composer/autoload.php`, because `OC_App::registerAutoloading()` uses `require_once` and that - * entrypoint may already have been included before `Installer::updateAppstoreApp()` hands control - * to `AppManager::upgradeApp()` for the real migration execution. - * - * References: - * - LibreSign/libresign#7892 - * - nextcloud/calendar_resource_management#238 - * - * If this step is ever removed or changed, reproduce those upgrade scenarios first and confirm that - * new LibreSign migrations still load correctly in both web- and OCC-driven upgrades. - */ -final class RefreshMigrationClassMap implements IRepairStep { - #[\Override] - public function getName(): string { - return 'Refresh LibreSign migration class map'; - } - - #[\Override] - public function run(IOutput $output): void { - UpgradeSafeAutoloader::registerCurrentAppLoader(dirname(__DIR__, 2)); - } -} diff --git a/lib/Migration/UpgradeSafeAutoloader.php b/lib/Migration/UpgradeSafeAutoloader.php new file mode 100644 index 0000000000..73edba1b87 --- /dev/null +++ b/lib/Migration/UpgradeSafeAutoloader.php @@ -0,0 +1,197 @@ + + */ + private static array $registeredRoots = []; + + private function __construct() { + } + + public static function register(ClassLoader $loader, string $appRoot): void { + $normalizedRoot = rtrim($appRoot, DIRECTORY_SEPARATOR); + if ($normalizedRoot === '' || isset(self::$registeredRoots[$normalizedRoot])) { + return; + } + + $replacementLoader = self::buildCurrentLoader($normalizedRoot); + if (!$replacementLoader instanceof ClassLoader) { + return; + } + + $loader->unregister(); + $replacementLoader->register(true); + self::loadComposerFiles($normalizedRoot); + self::$registeredRoots[$normalizedRoot] = true; + } + + public static function registerCurrentAppLoader(string $appRoot): void { + $loader = self::findRegisteredLoader(rtrim($appRoot, DIRECTORY_SEPARATOR)); + if (!$loader instanceof ClassLoader) { + return; + } + + self::register($loader, $appRoot); + } + + /** + * @return array>|null + */ + private static function readPsr0Namespaces(string $appRoot): ?array { + return self::readComposerArrayFile($appRoot, self::AUTOLOAD_NAMESPACES_FILE); + } + + /** + * @return array>|null + */ + private static function readPsr4Namespaces(string $appRoot): ?array { + return self::readComposerArrayFile($appRoot, self::AUTOLOAD_PSR4_FILE); + } + + /** + * @return array|null + */ + private static function readCurrentClassMap(string $appRoot): ?array { + return self::readComposerArrayFile($appRoot, self::CLASSMAP_FILE); + } + + /** + * @return array|null + */ + private static function readComposerFiles(string $appRoot): ?array { + return self::readComposerArrayFile($appRoot, self::AUTOLOAD_FILES_FILE); + } + + /** + * @return array|null + */ + private static function readComposerArrayFile(string $appRoot, string $relativeFile): ?array { + $file = $appRoot . $relativeFile; + if (!is_file($file)) { + return null; + } + + $data = require $file; + if (!is_array($data)) { + return null; + } + + return $data; + } + + private static function buildCurrentLoader(string $appRoot): ?ClassLoader { + $psr0Namespaces = self::readPsr0Namespaces($appRoot); + $psr4Namespaces = self::readPsr4Namespaces($appRoot); + $classMap = self::readCurrentClassMap($appRoot); + if ($psr0Namespaces === null || $psr4Namespaces === null || $classMap === null) { + return null; + } + + $loader = new ClassLoader($appRoot . '/vendor'); + foreach ($psr0Namespaces as $prefix => $paths) { + if (!is_string($prefix)) { + continue; + } + $normalizedPaths = self::normalizePathList($paths); + if ($normalizedPaths === []) { + continue; + } + $loader->add($prefix, $normalizedPaths); + } + + foreach ($psr4Namespaces as $prefix => $paths) { + if (!is_string($prefix)) { + continue; + } + $normalizedPaths = self::normalizePathList($paths); + if ($normalizedPaths === []) { + continue; + } + $loader->addPsr4($prefix, $normalizedPaths); + } + + $loader->addClassMap(self::normalizeClassMap($classMap)); + + return $loader; + } + + /** + * @param mixed $paths + * @return list + */ + private static function normalizePathList(mixed $paths): array { + if (is_string($paths)) { + return [$paths]; + } + + if (!is_array($paths)) { + return []; + } + + $normalizedPaths = []; + foreach ($paths as $path) { + if (is_string($path)) { + $normalizedPaths[] = $path; + } + } + + return $normalizedPaths; + } + + /** + * @param array $classMap + * @return array + */ + private static function normalizeClassMap(array $classMap): array { + $normalizedClassMap = []; + foreach ($classMap as $class => $path) { + if (is_string($class) && is_string($path)) { + $normalizedClassMap[$class] = $path; + } + } + + return $normalizedClassMap; + } + + private static function findRegisteredLoader(string $appRoot): ?ClassLoader { + $vendorDir = $appRoot . '/vendor'; + + return ClassLoader::getRegisteredLoaders()[$vendorDir] ?? null; + } + + private static function loadComposerFiles(string $appRoot): void { + $files = self::readComposerFiles($appRoot); + if ($files === null) { + return; + } + + if (!isset($GLOBALS['__composer_autoload_files']) || !is_array($GLOBALS['__composer_autoload_files'])) { + $GLOBALS['__composer_autoload_files'] = []; + } + + foreach ($files as $identifier => $file) { + if (!is_string($identifier) || !is_string($file) || isset($GLOBALS['__composer_autoload_files'][$identifier])) { + continue; + } + + $GLOBALS['__composer_autoload_files'][$identifier] = true; + require $file; + } + } +} From 89f77ed3e9b266b31c894d9f896005ab724593c9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:35:31 -0300 Subject: [PATCH 17/18] test: align migration autoloader coverage Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RefreshAutoloaderBeforeMigrationsTest.php | 91 +++++++++ .../RefreshMigrationClassMapTest.php | 59 ------ .../UpgradeSafeAutoloaderTest.php | 190 ++++++++++++++++-- 3 files changed, 265 insertions(+), 75 deletions(-) create mode 100644 tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php delete mode 100644 tests/php/Unit/Migration/RefreshMigrationClassMapTest.php rename tests/php/Unit/{Bootstrap => Migration}/UpgradeSafeAutoloaderTest.php (50%) diff --git a/tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php b/tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php new file mode 100644 index 0000000000..143b44b899 --- /dev/null +++ b/tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php @@ -0,0 +1,91 @@ +getName()); + } + + /** + * @runInSeparateProcess + */ + public function testRunRefreshesCurrentAppLoaderForNewMigrationFile(): void { + $appRoot = dirname(__DIR__, 4); + $helperFile = $appRoot . self::HELPER_FILE; + $migrationFile = $appRoot . self::MIGRATION_FILE; + @unlink($helperFile); + @unlink($migrationFile); + + try { + clearstatcache(true, $helperFile); + clearstatcache(true, $migrationFile); + self::assertFalse(class_exists(self::MIGRATION_CLASS)); + self::assertFalse(class_exists(self::HELPER_CLASS)); + + self::assertNotFalse(file_put_contents( + $helperFile, + <<<'PHP' + run($this->createMock(IOutput::class)); + + self::assertTrue(class_exists(self::MIGRATION_CLASS)); + self::assertTrue(class_exists(self::HELPER_CLASS)); + $migrationClass = self::MIGRATION_CLASS; + self::assertSame('pong', $migrationClass::run()); + } finally { + @unlink($helperFile); + @unlink($migrationFile); + } + } +} diff --git a/tests/php/Unit/Migration/RefreshMigrationClassMapTest.php b/tests/php/Unit/Migration/RefreshMigrationClassMapTest.php deleted file mode 100644 index dd63b9b906..0000000000 --- a/tests/php/Unit/Migration/RefreshMigrationClassMapTest.php +++ /dev/null @@ -1,59 +0,0 @@ -getName()); - } - - /** - * @runInSeparateProcess - */ - public function testRunRefreshesCurrentAppLoaderForNewMigrationFile(): void { - $appRoot = dirname(__DIR__, 4); - $migrationFile = $appRoot . self::MIGRATION_FILE; - @unlink($migrationFile); - - try { - clearstatcache(true, $migrationFile); - self::assertFalse(class_exists(self::MIGRATION_CLASS)); - - self::assertNotFalse(file_put_contents( - $migrationFile, - <<<'PHP' - run($this->createMock(IOutput::class)); - - self::assertTrue(class_exists(self::MIGRATION_CLASS)); - } finally { - @unlink($migrationFile); - } - } -} diff --git a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php similarity index 50% rename from tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php rename to tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php index 8ebc2d280e..e9b284cff5 100644 --- a/tests/php/Unit/Bootstrap/UpgradeSafeAutoloaderTest.php +++ b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php @@ -6,17 +6,22 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Tests\Unit\Bootstrap; +namespace OCA\Libresign\Tests\Unit\Migration; use Composer\Autoload\ClassLoader; -use OCA\Libresign\Bootstrap\UpgradeSafeAutoloader; +use OCA\Libresign\Migration\UpgradeSafeAutoloader; use PHPUnit\Framework\TestCase; final class UpgradeSafeAutoloaderTest extends TestCase { private const AUTOLOAD_COUNTER_KEY = 'libresign_upgrade_safe_autoloader_boot_count'; + private const HELPER_CLASS = 'OCA\\Libresign\\Service\\FreshHelper'; + private const HELPER_FILE = '/lib/Service/FreshHelper.php'; private const MIGRATION_CLASS = 'OCA\\Libresign\\Migration\\Version99999Date20260713000000'; - private const NON_MIGRATION_CLASS = 'OCA\\Libresign\\Service\\GeneratedNonMigrationClass'; private const MIGRATION_FILE = '/lib/Migration/Version99999Date20260713000000.php'; + private const PROBE_FUNCTION = 'libresign_upgrade_safe_probe_function'; + private const PROBE_FUNCTION_FILE = '/support/runtime-functions.php'; + private const VENDOR_CLASS = 'OCA\\Libresign\\Vendor\\Demo\\Widget'; + private const VENDOR_FILE = '/vendor-prefixed/Demo/Widget.php'; /** * @runInSeparateProcess @@ -29,6 +34,7 @@ public function testLoadsMigrationClassAfterComposerCachedPreviousMiss(): void { try { mkdir($appRoot . '/lib/Migration', 0755, true); + self::writeComposerMetadata($appRoot); self::assertFalse(class_exists(self::MIGRATION_CLASS)); @@ -48,9 +54,12 @@ final class Version99999Date20260713000000 { UpgradeSafeAutoloader::register($loader, $appRoot); + $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null; + self::assertInstanceOf(ClassLoader::class, $registeredLoader); + self::assertNotSame($loader, $registeredLoader); self::assertTrue(class_exists(self::MIGRATION_CLASS)); } finally { - $loader->unregister(); + self::unregisterLoaderForAppRoot($appRoot); self::removeDirectoryRecursively($appRoot); } } @@ -60,12 +69,16 @@ final class Version99999Date20260713000000 { */ public function testRequireOnceDoesNotReloadComposerAutoloadAfterMigrationFileAppears(): void { $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); + $loader = null; unset($GLOBALS[self::AUTOLOAD_COUNTER_KEY]); try { mkdir($appRoot . '/composer', 0755, true); mkdir($appRoot . '/vendor', 0755, true); mkdir($appRoot . '/lib/Migration', 0755, true); + mkdir($appRoot . '/lib/Service', 0755, true); + mkdir($appRoot . '/vendor-prefixed/Demo', 0755, true); + self::writeComposerMetadata($appRoot); file_put_contents( $appRoot . '/vendor/autoload.php', @@ -96,6 +109,8 @@ public function testRequireOnceDoesNotReloadComposerAutoloadAfterMigrationFileAp self::assertSame(1, $GLOBALS[self::AUTOLOAD_COUNTER_KEY]); self::assertFalse(class_exists(self::MIGRATION_CLASS)); + self::assertFalse(class_exists(self::HELPER_CLASS)); + self::assertFalse(class_exists(self::VENDOR_CLASS)); file_put_contents( $appRoot . self::MIGRATION_FILE, @@ -106,22 +121,72 @@ public function testRequireOnceDoesNotReloadComposerAutoloadAfterMigrationFileAp namespace OCA\Libresign\Migration; +use OCA\Libresign\Service\FreshHelper; +use OCA\Libresign\Vendor\Demo\Widget; + final class Version99999Date20260713000000 { + public static function run(): string { + return FreshHelper::ping() . '-' . Widget::name(); + } +} +PHP, + ); + + file_put_contents( + $appRoot . self::HELPER_FILE, + <<<'PHP' + $appRoot . self::VENDOR_FILE, + ]); + require_once $appRoot . '/composer/autoload.php'; self::assertSame(1, $GLOBALS[self::AUTOLOAD_COUNTER_KEY]); self::assertFalse(class_exists(self::MIGRATION_CLASS)); + self::assertFalse(class_exists(self::HELPER_CLASS)); + self::assertFalse(class_exists(self::VENDOR_CLASS)); UpgradeSafeAutoloader::registerCurrentAppLoader($appRoot); + $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null; + self::assertInstanceOf(ClassLoader::class, $registeredLoader); + self::assertNotSame($loader, $registeredLoader); self::assertTrue(class_exists(self::MIGRATION_CLASS)); + self::assertTrue(class_exists(self::HELPER_CLASS)); + self::assertTrue(class_exists(self::VENDOR_CLASS)); + $migrationClass = self::MIGRATION_CLASS; + self::assertSame('pong-widget', $migrationClass::run()); } finally { - $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null; - if ($registeredLoader instanceof ClassLoader) { - $registeredLoader->unregister(); - } + self::unregisterLoaderForAppRoot($appRoot); unset($GLOBALS[self::AUTOLOAD_COUNTER_KEY]); self::removeDirectoryRecursively($appRoot); } @@ -138,6 +203,7 @@ public function testFindsRegisteredLoaderForCurrentAppRoot(): void { try { mkdir($appRoot . '/lib/Migration', 0755, true); + self::writeComposerMetadata($appRoot); self::assertFalse(class_exists(self::MIGRATION_CLASS)); @@ -157,9 +223,12 @@ final class Version99999Date20260713000000 { UpgradeSafeAutoloader::registerCurrentAppLoader($appRoot); + $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null; + self::assertInstanceOf(ClassLoader::class, $registeredLoader); + self::assertNotSame($loader, $registeredLoader); self::assertTrue(class_exists(self::MIGRATION_CLASS)); } finally { - $loader->unregister(); + self::unregisterLoaderForAppRoot($appRoot); self::removeDirectoryRecursively($appRoot); } } @@ -167,37 +236,126 @@ final class Version99999Date20260713000000 { /** * @runInSeparateProcess */ - public function testRegistersOnlyMigrationClasses(): void { + public function testLoadsVendorClassFromFreshComposerLoader(): void { $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); $loader = new ClassLoader($appRoot . '/vendor'); + $loader->addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true); $loader->register(true); try { - mkdir($appRoot . '/lib/Service', 0755, true); + mkdir($appRoot . '/vendor-prefixed/Demo', 0755, true); file_put_contents( - $appRoot . '/lib/Service/GeneratedNonMigrationClass.php', + $appRoot . self::VENDOR_FILE, <<<'PHP' $appRoot . self::VENDOR_FILE, + ]); + + self::assertFalse(class_exists(self::VENDOR_CLASS)); + + UpgradeSafeAutoloader::register($loader, $appRoot); + + $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null; + self::assertInstanceOf(ClassLoader::class, $registeredLoader); + self::assertNotSame($loader, $registeredLoader); + self::assertTrue(class_exists(self::VENDOR_CLASS)); + $vendorClass = self::VENDOR_CLASS; + self::assertSame('widget', $vendorClass::name()); + } finally { + self::unregisterLoaderForAppRoot($appRoot); + self::removeDirectoryRecursively($appRoot); + } + } + + /** + * @runInSeparateProcess + */ + public function testLoadsComposerFilesFromFreshLoader(): void { + $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true); + $loader = new ClassLoader($appRoot . '/vendor'); + $loader->register(true); + + try { + mkdir(dirname($appRoot . self::PROBE_FUNCTION_FILE), 0755, true); + + file_put_contents( + $appRoot . self::PROBE_FUNCTION_FILE, + <<<'PHP' + $appRoot . self::PROBE_FUNCTION_FILE, + ]); + + self::assertFalse(function_exists(self::PROBE_FUNCTION)); + UpgradeSafeAutoloader::register($loader, $appRoot); - self::assertFalse(class_exists(self::NON_MIGRATION_CLASS)); + self::assertTrue(function_exists(self::PROBE_FUNCTION)); + $function = self::PROBE_FUNCTION; + self::assertSame('ok', $function()); } finally { - $loader->unregister(); + self::unregisterLoaderForAppRoot($appRoot); self::removeDirectoryRecursively($appRoot); } } + private static function writeComposerMetadata(string $appRoot, array $classMap = [], array $files = []): void { + $directory = $appRoot . '/vendor/composer'; + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents( + $directory . '/autoload_namespaces.php', + " [$appRoot . '/lib'], + ], true) . ";\n", + ); + file_put_contents( + $directory . '/autoload_classmap.php', + "unregister(); + } + } + private static function removeDirectoryRecursively(string $path): void { if (!file_exists($path)) { return; From b604217e1f1a63d18d3757e51b4b00963a4a61da Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:10:31 -0300 Subject: [PATCH 18/18] test: drop brittle autoloader fallback Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php index e9b284cff5..5d1773486b 100644 --- a/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php +++ b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php @@ -84,9 +84,6 @@ public function testRequireOnceDoesNotReloadComposerAutoloadAfterMigrationFileAp $appRoot . '/vendor/autoload.php', "