diff --git a/appinfo/info.xml b/appinfo/info.xml index 7617ab4293..eca901211f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -63,6 +63,9 @@ https://libresign.coop OCA\Libresign\BackgroundJob\SignSingleFileJob + + OCA\Libresign\Migration\RefreshAutoloaderBeforeMigrations + OCA\Libresign\Migration\DeleteOldBinaries OCA\Libresign\Migration\ResynchronizeDatabaseSequences 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/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; + } + } +} 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/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php new file mode 100644 index 0000000000..5d1773486b --- /dev/null +++ b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php @@ -0,0 +1,382 @@ +addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true); + $loader->register(true); + + try { + mkdir($appRoot . '/lib/Migration', 0755, true); + self::writeComposerMetadata($appRoot); + + self::assertFalse(class_exists(self::MIGRATION_CLASS)); + + file_put_contents( + $appRoot . self::MIGRATION_FILE, + <<<'PHP' +addPsr4('OCA\\\\Libresign\\\\', [dirname(__DIR__) . '/lib'], true);\n" + . " \$loader->register(true);\n" + . "}\n" + . "return \$loader;\n", + ); + + file_put_contents( + $appRoot . '/composer/autoload.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 { + self::unregisterLoaderForAppRoot($appRoot); + unset($GLOBALS[self::AUTOLOAD_COUNTER_KEY]); + 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::writeComposerMetadata($appRoot); + + self::assertFalse(class_exists(self::MIGRATION_CLASS)); + + file_put_contents( + $appRoot . self::MIGRATION_FILE, + <<<'PHP' +addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true); + $loader->register(true); + + try { + mkdir($appRoot . '/vendor-prefixed/Demo', 0755, true); + + file_put_contents( + $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::assertTrue(function_exists(self::PROBE_FUNCTION)); + $function = self::PROBE_FUNCTION; + self::assertSame('ok', $function()); + } finally { + 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; + } + + if (is_file($path)) { + @unlink($path); + return; + } + + $entries = scandir($path); + if (!is_array($entries)) { + @rmdir($path); + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + self::removeDirectoryRecursively($path . '/' . $entry); + } + + @rmdir($path); + } +}