Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ https://libresign.coop
<job>OCA\Libresign\BackgroundJob\SignSingleFileJob</job>
</background-jobs>
<repair-steps>
<pre-migration>
<step>OCA\Libresign\Migration\RefreshAutoloaderBeforeMigrations</step>
</pre-migration>
<post-migration>
<step>OCA\Libresign\Migration\DeleteOldBinaries</step>
<step>OCA\Libresign\Migration\ResynchronizeDatabaseSequences</step>
Expand Down
52 changes: 52 additions & 0 deletions lib/Migration/RefreshAutoloaderBeforeMigrations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Migration;

use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;

/**
* Refresh the LibreSign Composer autoloader immediately before Nextcloud executes migrations.
*
* 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 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));
}
}
197 changes: 197 additions & 0 deletions lib/Migration/UpgradeSafeAutoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Migration;

use Composer\Autoload\ClassLoader;

final class UpgradeSafeAutoloader {
private const AUTOLOAD_FILES_FILE = '/vendor/composer/autoload_files.php';
private const AUTOLOAD_NAMESPACES_FILE = '/vendor/composer/autoload_namespaces.php';
private const AUTOLOAD_PSR4_FILE = '/vendor/composer/autoload_psr4.php';
private const CLASSMAP_FILE = '/vendor/composer/autoload_classmap.php';

/**
* @var array<string, true>
*/
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<string, list<string>>|null
*/
private static function readPsr0Namespaces(string $appRoot): ?array {
return self::readComposerArrayFile($appRoot, self::AUTOLOAD_NAMESPACES_FILE);
}

/**
* @return array<string, list<string>>|null
*/
private static function readPsr4Namespaces(string $appRoot): ?array {
return self::readComposerArrayFile($appRoot, self::AUTOLOAD_PSR4_FILE);
}

/**
* @return array<string, string>|null
*/
private static function readCurrentClassMap(string $appRoot): ?array {
return self::readComposerArrayFile($appRoot, self::CLASSMAP_FILE);
}

/**
* @return array<string, string>|null
*/
private static function readComposerFiles(string $appRoot): ?array {
return self::readComposerArrayFile($appRoot, self::AUTOLOAD_FILES_FILE);
}

/**
* @return array<mixed>|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<string>
*/
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<mixed> $classMap
* @return array<string, string>
*/
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;
}
}
}
91 changes: 91 additions & 0 deletions tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Tests\Unit\Migration;

use OCA\Libresign\Migration\RefreshAutoloaderBeforeMigrations;
use OCP\Migration\IOutput;
use PHPUnit\Framework\TestCase;

final class RefreshAutoloaderBeforeMigrationsTest extends TestCase {
private const HELPER_CLASS = 'OCA\\Libresign\\Service\\FreshRuntimeHelper';
private const HELPER_FILE = '/lib/Service/FreshRuntimeHelper.php';
private const MIGRATION_CLASS = 'OCA\\Libresign\\Migration\\Version99999Date20260713030303';
private const MIGRATION_FILE = '/lib/Migration/Version99999Date20260713030303.php';

public function testGetName(): void {
$step = new RefreshAutoloaderBeforeMigrations();

self::assertSame('Refresh LibreSign autoloader before migrations', $step->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'
<?php

declare(strict_types=1);

namespace OCA\Libresign\Service;

final class FreshRuntimeHelper {
public static function ping(): string {
return 'pong';
}
}
PHP,
));

self::assertNotFalse(file_put_contents(
$migrationFile,
<<<'PHP'
<?php

declare(strict_types=1);

namespace OCA\Libresign\Migration;

use OCA\Libresign\Service\FreshRuntimeHelper;

final class Version99999Date20260713030303 {
public static function run(): string {
return FreshRuntimeHelper::ping();
}
}
PHP,
));

$step = new RefreshAutoloaderBeforeMigrations();
$step->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);
}
}
}
Loading
Loading