Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public function __construct(array $urlParams = []) {
#[\Override]
public function register(IRegistrationContext $context): void {
include_once __DIR__ . '/../../vendor/autoload.php';
// Make sure rubix's free functions and constants are defined regardless of
// Composer's "files" autoloader dedupe (see the file for details).
require_once __DIR__ . '/../RubixBootstrap.php';

$context->registerEventListener(SuspiciousLoginEvent::class, LoginNotificationListener::class);
$context->registerEventListener(SuspiciousLoginEvent::class, LoginMailListener::class);
Expand Down
44 changes: 44 additions & 0 deletions lib/RubixBootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/*
* rubix/ml and rubix/tensor expose free functions and constants through
* Composer's "files" autoloader rather than class autoloading (PHP does not
* autoload functions or constants).
*
* That loader dedupes by a content-blind identifier, md5("<package>:<path>"),
* stored process-wide in $GLOBALS['__composer_autoload_files']. Several apps
* bundle rubix and register the very same identifier, so only the first app to
* boot actually loads its copy; every other app's files loop is skipped. The
* catch is that those copies are not interchangeable:
*
* - an app that php-scopes rubix (e.g. recognize) only defines prefixed
* symbols such as OCA\Recognize\Vendor\Rubix\ML\sigmoid, so when it wins the
* race our un-scoped Rubix\ML\sigmoid is never defined and inference crashes
* with "Tensor\Matrix::map(): Argument #1 ($callback) must be of type
* callable, string given" (issue #1113);
* - an app that ships rubix un-scoped (e.g. mail) defines the very same
* Rubix\ML\* symbols we do, from a different path.
*
* So we must make sure our symbols exist, but only load our copy when they are
* actually missing: blindly requiring the files would fatal with "Cannot
* redeclare function Rubix\ML\argmin()" whenever another un-scoped copy already
* loaded them. require_once is not enough here - it dedupes by realpath, not by
* symbol, so it does not protect against another app's copy. Guard on a
* representative symbol from each file instead.
*/
if (!function_exists('Rubix\ML\sigmoid')) {
require_once __DIR__ . '/../vendor/rubix/ml/src/functions.php';
}
if (!defined('Rubix\ML\HALF_PI')) {
require_once __DIR__ . '/../vendor/rubix/ml/src/constants.php';
}
if (!defined('Tensor\EPSILON')) {
require_once __DIR__ . '/../vendor/rubix/tensor/src/constants.php';
}
4 changes: 4 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<ignoreFiles>
<directory name="vendor" />
<directory name="vendor-bin" />
<!-- Procedural subprocess fixtures that deliberately poke rubix
internals and Composer's autoload globals to reproduce #1113. -->
<file name="tests/Unit/AppInfo/rubix-collision-repro.php" />
<file name="tests/Unit/AppInfo/rubix-redeclare-repro.php" />
</ignoreFiles>
</projectFiles>
<extraFiles>
Expand Down
52 changes: 52 additions & 0 deletions tests/Unit/AppInfo/ApplicationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\SuspiciousLogin\Tests\Unit\AppInfo;

use ChristophWurst\Nextcloud\Testing\TestCase;

class ApplicationTest extends TestCase {

/**
* Regression test for #1113: when another app wins Composer's "files"
* autoload dedupe race, our un-scoped rubix functions get skipped and
* inference crashes with "Tensor\Matrix::map(): ... must be of type
* callable, string given". RubixBootstrap.php must load them explicitly.
*
* The scenario only manifests across a fresh autoload order, so it is
* reproduced in a subprocess.
*/
public function testRubixFunctionsSurviveAutoloadDedupeCollision(): void {
$script = __DIR__ . '/rubix-collision-repro.php';

$output = [];
$exitCode = -1;
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode);

$message = implode("\n", $output);
self::assertNotSame(2, $exitCode, "Collision could not be reproduced, the test is stale:\n$message");
self::assertSame(0, $exitCode, "Sigmoid activation failed despite RubixBootstrap.php:\n$message");
}

/**
* Counterpart to the test above: when another app ships rubix un-scoped and
* has already defined the Rubix\ML\* symbols, RubixBootstrap.php must not
* reload its own copy - that would fatal with "Cannot redeclare function
* Rubix\ML\argmin()". Reproduced in a subprocess for a clean symbol table.
*/
public function testRubixBootstrapDoesNotRedeclareExistingSymbols(): void {
$script = __DIR__ . '/rubix-redeclare-repro.php';

$output = [];
$exitCode = -1;
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode);

self::assertSame(0, $exitCode, "RubixBootstrap.php redeclared rubix symbols that already existed:\n" . implode("\n", $output));
}
}
52 changes: 52 additions & 0 deletions tests/Unit/AppInfo/rubix-collision-repro.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/*
* Standalone reproduction of issue #1113, run in a fresh PHP subprocess so we
* fully control Composer's file autoload order (the bug cannot be reproduced
* in-process because PHPUnit's bootstrap already loaded the rubix functions).
*
* It simulates another app (e.g. recognize) having won the composer "files"
* dedupe race by pre-marking rubix's file identifiers as loaded *before* our
* autoloader runs, so our un-scoped functions.php/constants.php get skipped.
* Then it loads our RubixBootstrap.php (the fix under test) and asserts the
* sigmoid activation - which relies on the string callable 'Rubix\ML\sigmoid' -
* works again.
*
* Exit codes: 0 = fix works, 2 = collision could not be reproduced (bug guard
* is stale), anything else (incl. 255 on TypeError) = the fix did not help.
*/

$appRoot = dirname(__DIR__, 3);

// File identifiers Composer assigns to the rubix files we depend on; these are
// md5("<package>:<path>") and are therefore identical in every install,
// including the scoped copy shipped by other apps.
$GLOBALS['__composer_autoload_files']['0315e8fd3e479309d097647b8ef2920b'] = true; // rubix/ml src/functions.php
$GLOBALS['__composer_autoload_files']['702239352e6628be5dc71b6fd029e72e'] = true; // rubix/ml src/constants.php
$GLOBALS['__composer_autoload_files']['8f758069bf9eb3411d096c10be343745'] = true; // rubix/tensor src/constants.php

require $appRoot . '/vendor/autoload.php';

// Guard: with the flags pre-set, our files loop must have skipped functions.php.
// If the function is already defined here the identifiers drifted and this test
// no longer reproduces the bug, so fail loudly instead of passing silently.
if (function_exists('Rubix\ML\sigmoid')) {
fwrite(STDERR, "collision not reproduced: Rubix\\ML\\sigmoid already defined\n");
exit(2);
}

// The fix: explicitly load our own copies regardless of the dedupe flag.
require_once $appRoot . '/lib/RubixBootstrap.php';

// Exercise the exact path from the crash report.
$result = (new Rubix\ML\NeuralNet\ActivationFunctions\Sigmoid())
->activate(Tensor\Matrix::quick([[0.5, -1.0], [2.0, 0.0]]));

exit($result instanceof Tensor\Matrix ? 0 : 3);
42 changes: 42 additions & 0 deletions tests/Unit/AppInfo/rubix-redeclare-repro.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/*
* Companion to rubix-collision-repro.php for the opposite hazard: another app
* (e.g. mail) ships rubix *un-scoped* and has already defined the Rubix\ML\* /
* Tensor\* symbols - from a different path - before we boot. RubixBootstrap.php
* must notice the symbols already exist and NOT reload its own copy, otherwise
* PHP fatals with "Cannot redeclare function Rubix\ML\argmin()" (or warns about
* a redeclared constant).
*
* The pre-existing symbols are synthesised here instead of relying on another
* app being installed, so this runs on a clean CI runner. With a correct guard
* RubixBootstrap.php is a no-op; without it the require_once below redeclares
* the synthesised symbols.
*
* Exit codes: 0 = no reload, 4 = a redeclare warning fired, 255 = redeclare
* fatal.
*/

$appRoot = dirname(__DIR__, 3);

// Make a "Constant ... already defined" warning fail the run as well.
set_error_handler(static function (int $no, string $msg): bool {
fwrite(STDERR, "unexpected error: $msg\n");
exit(4);
});

// Pretend another un-scoped rubix copy already defined the symbols we guard on.
eval('namespace Rubix\ML { function sigmoid(float $v): float { return $v; } const HALF_PI = 1.0; }');
eval('namespace Tensor { const EPSILON = 1e-8; }');

require $appRoot . '/lib/RubixBootstrap.php';

echo "ok\n";
exit(0);
Loading