Commit 6a0befe4628 for woocommerce
commit 6a0befe4628163ebf238f7f6dd05772cb06178ee
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Jun 17 13:49:55 2026 +0300
Fix PSR-4 upgrade fallback fataling on a non-array loader shape (#65674)
* fix(autoloader): degrade on a non-array getPrefixesPsr4() shape
The in-place-upgrade PSR-4 fallback (#65338) promises to degrade to "no
fallback" rather than fatal the bootstrap. register_woocommerce_psr4_fallback()
wrapped build() + getPrefixesPsr4() in a Throwable guard, so a getPrefixesPsr4()
that THROWS is handled — but it captured the return with no is_array() check. A
foreign/ancient Composer ClassLoader (one another plugin or wp-cli loaded from a
different path, then reused by build()) whose getPrefixesPsr4() RETURNS a
non-array sails through the guard: the method carries no return-type declaration,
so a non-array return is legal. That value is captured into the handler closure
and passed, on the first autoload miss, into find_scoped_file()'s typed
`array $psr4_entries` parameter — outside the handler's try/catch — raising an
uncatchable TypeError. A defensive class_exists('Automattic\WooCommerce\...')
probe during an upgrade would then fatal the request instead of getting false.
Extract the getPrefixesPsr4() read into read_scoped_psr4_map(), which returns
null on any non-array shape, and have register() bail to "no fallback" when it
does. This mirrors the is_array() guard build() already applies to the
file-sourced map (Autoloader.php:101), making the two map sources symmetric and
the docblock's degrade-don't-fatal contract actually whole.
Reproduced end-to-end before the fix: with a foreign loader preloaded, a
class_exists() probe fataled with the uncaught TypeError at find_scoped_file();
after the fix register() returns null and the probe returns false. A genuine
loader still registers and resolves src classes. Two regression tests pin both
paths (the foreign non-array shape is modelled by subclassing ClassLoader and
overriding getPrefixesPsr4(), legal because the parent declares no return type).
Refs #65672
* fix(autoloader): guard findFile()'s untyped return in the PSR-4 fallback
Composer\Autoload\ClassLoader::findFile() carries no return-type
declaration, so a foreign or ancient loader reused by build() can legally
return a non-string. find_scoped_file() captured that result into its
`: ?string` return via `return false !== $file ? $file : null;`, which runs
outside the registered handler's own try/catch — so a non-string (array or
object) slips past the `false !==` check and raises an uncatchable TypeError
on the first autoload miss. That is the exact shape #65630 closed for
getPrefixesPsr4(), making the "last uncatchable path" framing incomplete: a
defensive class_exists() probe during an in-place upgrade would still fatal
the request instead of getting false.
Extract read_scoped_file_path() as the sibling of read_scoped_psr4_map(): it
reads findFile() and degrades any non-string to null (including Composer's
own `false` miss sentinel, which `is_string()` already excludes).
find_scoped_file() now returns through it, so a foreign loader misses
cleanly rather than fataling. Behaviour on every valid loader is unchanged.
Two tests pin the contract, mirroring the getPrefixesPsr4 pair.
Refs #65672
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(autoloader): log under WP_DEBUG when the PSR-4 fallback declines
The PSR-4 fallback registers during bootstrap and silently returns null on
each of its three decline paths: the Composer files are unavailable or a
foreign ClassLoader shape is rejected by build(), getPrefixesPsr4() returns
a non-array, or building the availability probe throws. When the fallback
declines in production, the downstream "class not found" fatal an operator
eventually sees during an in-place upgrade carries no breadcrumb back to
this decision — yet that is the most useful signal in the system, since the
fallback exists precisely to prevent that fatal.
Route the three branches through a log_fallback_declined() helper that
error_logs a branch-specific reason under WP_DEBUG only, mirroring the
breadcrumb the registered handler already emits for a caught autoload error.
No behaviour change outside WP_DEBUG.
Refs #65672
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(autoloader): tighten the @internal visibility justifications
The @internal notes on read_scoped_psr4_map(), read_scoped_file_path(), and
find_scoped_file() each cited a same-class caller as a reason these methods
are public — but PHP does not require public visibility for self:: calls, so
only the unit tests (a separate class) actually need it. Reword to state that
plainly while keeping the production-caller cross-reference, so the
visibility rationale is accurate.
Refs #65672
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(autoloader): pin findFile()'s false-miss promotion to null
read_scoped_file_path()'s two existing tests cover a foreign non-string
(array) return and a resolving class, but neither asserts the case
findFile() actually returns most often: the literal `false` on a class miss.
That false-to-null promotion is load-bearing — it is what keeps a miss from
surfacing as a non-string and TypeError-ing at find_scoped_file()'s
`: ?string` return, outside the handler's try/catch.
Add a test driving a genuine ClassLoader with a non-existent class so
findFile() returns false, asserting read_scoped_file_path() yields null.
Refs #65672
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(autoloader): document the register_() wiring coverage boundary
Two reviewers asked for an end-to-end test that
register_woocommerce_psr4_fallback() returns null (nothing added to the SPL
stack) when the probe loader is foreign-shaped. That path is not reachable
from a test without two production seams: register_() binds
build_woocommerce_psr4_fallback() with early static binding and memoizes its
handler in a function-static the woocommerce.php bootstrap populates before
any test runs, which PHP cannot reset.
Rather than add testing seams to a bootstrap surface for an unreleased fix,
record the boundary in the test class docblock: the decline-to-null logic is
covered at the helper level (read_scoped_psr4_map / read_scoped_file_path),
and the next person who refactors the register_() wiring knows the seam is
missing rather than assuming it is tested.
Refs #65672
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-psr4-fallback-validate-getprefixespsr4 b/plugins/woocommerce/changelog/fix-psr4-fallback-validate-getprefixespsr4
new file mode 100644
index 00000000000..734dd29195e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-psr4-fallback-validate-getprefixespsr4
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the PSR-4 upgrade fallback fataling when a foreign Composer ClassLoader returns a non-array from getPrefixesPsr4() or a non-string from findFile(); validate both shapes so it degrades to no fallback instead.
diff --git a/plugins/woocommerce/src/Autoloader.php b/plugins/woocommerce/src/Autoloader.php
index 7ad57d6e070..70cb33200b0 100644
--- a/plugins/woocommerce/src/Autoloader.php
+++ b/plugins/woocommerce/src/Autoloader.php
@@ -160,15 +160,27 @@ class Autoloader {
// rebuilds a throwaway loader per miss from this captured map (for performance — the map
// is read once, not on every miss). Do NOT collapse this into a shared loader or a per-miss
// build() call: either reintroduces the negative-cache bug the fresh-per-miss design avoids.
- // Wrapped so a foreign/malformed ClassLoader shape (an unexpected getPrefixesPsr4()) degrades
- // to "no fallback" rather than fataling the bootstrap — matching build()'s own contract.
+ //
+ // A foreign/malformed ClassLoader shape must degrade to "no fallback" rather than fatal the
+ // bootstrap — matching build()'s own contract. The guard is twofold, because the map reaches
+ // find_scoped_file()'s `array $psr4_entries` parameter on every miss, outside the handler's
+ // own try/catch: the try/catch here handles a getPrefixesPsr4() that THROWS, and
+ // read_scoped_psr4_map() handles one that RETURNS a non-array (the method carries no
+ // return-type declaration, so an older/foreign loader can) — which would otherwise raise an
+ // uncatchable TypeError on the first autoload miss.
try {
$availability_probe = self::build_woocommerce_psr4_fallback();
if ( null === $availability_probe ) {
+ self::log_fallback_declined( 'the Composer files are unavailable or a foreign ClassLoader shape was rejected by build()' );
+ return null;
+ }
+ $psr4_entries = self::read_scoped_psr4_map( $availability_probe );
+ if ( null === $psr4_entries ) {
+ self::log_fallback_declined( 'getPrefixesPsr4() returned a non-array shape' );
return null;
}
- $psr4_entries = $availability_probe->getPrefixesPsr4();
} catch ( \Throwable $e ) {
+ self::log_fallback_declined( 'building the availability probe threw: ' . $e->getMessage() );
return null;
}
@@ -261,6 +273,84 @@ class Autoloader {
return $handler;
}
+ /**
+ * Log, under WP_DEBUG only, why the PSR-4 fallback declined to register.
+ *
+ * When the fallback bails to "no fallback", the downstream "class not found" fatal an operator
+ * eventually sees during an in-place upgrade carries no breadcrumb back to this decision — yet
+ * that breadcrumb is the most useful signal in the system, since the fallback exists precisely
+ * to prevent that fatal. Mirrors the WP_DEBUG error_log the registered handler already emits for
+ * a caught autoload error.
+ *
+ * @since 11.0.0
+ *
+ * @param string $reason Human-readable reason the fallback was not registered.
+ */
+ private static function log_fallback_declined( string $reason ): void {
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ 'WooCommerce PSR-4 fallback not registered: ' . $reason
+ );
+ }
+ }
+
+ /**
+ * Read the scoped PSR-4 prefix map out of a built fallback loader, degrading to null on any
+ * non-array shape.
+ *
+ * {@see ClassLoader::getPrefixesPsr4()} carries no return-type declaration, so an older or
+ * foreign `Composer\Autoload\ClassLoader` — one another plugin or wp-cli loaded from a
+ * different path, then reused by {@see self::build_woocommerce_psr4_fallback()} — may return a
+ * non-array. The registered handler passes this map straight into {@see self::find_scoped_file()},
+ * whose `array $psr4_entries` parameter would raise an uncatchable TypeError on the first
+ * autoload miss, outside the handler's own try/catch. Validating here keeps the fallback's
+ * degrade-don't-fatal contract whole, mirroring the is_array() guard build() already applies to
+ * the file-sourced map.
+ *
+ * @internal Public only so the unit tests can reach it; its sole production caller is
+ * {@see self::register_woocommerce_psr4_fallback()}.
+ *
+ * @since 11.0.0
+ *
+ * @param ClassLoader $loader A loader returned by build_woocommerce_psr4_fallback().
+ *
+ * @return array<string, list<string>>|null The scoped PSR-4 map, or null on a non-array shape.
+ */
+ public static function read_scoped_psr4_map( ClassLoader $loader ): ?array {
+ $psr4_entries = $loader->getPrefixesPsr4();
+
+ return is_array( $psr4_entries ) ? $psr4_entries : null;
+ }
+
+ /**
+ * Read a resolved file path out of a fallback loader's findFile(), degrading to null on any
+ * non-string shape.
+ *
+ * The sibling of {@see self::read_scoped_psr4_map()}: {@see ClassLoader::findFile()} carries no
+ * return-type declaration either, so the same older or foreign `Composer\Autoload\ClassLoader`
+ * reused by {@see self::build_woocommerce_psr4_fallback()} may return a non-string. The caller,
+ * {@see self::find_scoped_file()}, declares a `: ?string` return, so a non-string result would
+ * raise an uncatchable TypeError at that return statement — which, on an autoload miss, runs
+ * outside the registered handler's own try/catch, exactly the shape this fallback guards against
+ * for getPrefixesPsr4(). Composer's own miss sentinel is `false`, which is not a string and so
+ * degrades to null here, unchanged. Validating keeps the degrade-don't-fatal contract whole.
+ *
+ * @internal Public only so the unit tests can reach it; its sole production caller is
+ * {@see self::find_scoped_file()}.
+ *
+ * @since 11.0.0
+ *
+ * @param ClassLoader $loader A loader built from the scoped PSR-4 map.
+ * @param string $class_name Fully-qualified class name to resolve.
+ *
+ * @return string|null The resolved absolute file path, or null on a miss or non-string shape.
+ */
+ public static function read_scoped_file_path( ClassLoader $loader, string $class_name ): ?string {
+ $file = $loader->findFile( $class_name );
+
+ return is_string( $file ) ? $file : null;
+ }
+
/**
* Resolve a WooCommerce `src/` class to a file via a throwaway PSR-4 `ClassLoader`.
*
@@ -270,7 +360,8 @@ class Autoloader {
* file is on disk. Building fresh here guarantees a class missed pre-swap resolves post-swap,
* within the same request.
*
- * @internal Public only so the registered autoload handler and the unit tests can drive it.
+ * @internal Public only so the unit tests can reach it; in production only the registered
+ * autoload handler calls it.
*
* @param string $class_name Fully-qualified class name.
* @param array<string, list<string>> $psr4_entries Pre-scoped PSR-4 prefix => dirs map.
@@ -288,13 +379,14 @@ class Autoloader {
foreach ( $psr4_entries as $namespace => $paths ) {
$loader->setPsr4( $namespace, $paths );
}
- $file = $loader->findFile( $class_name );
+
+ // read_scoped_file_path() guards findFile()'s untyped return: a non-string would
+ // otherwise TypeError against this method's `: ?string`, outside the handler's try/catch.
+ return self::read_scoped_file_path( $loader, $class_name );
} catch ( \Throwable $e ) {
// Foreign/malformed ClassLoader — miss rather than fatal the autoload path.
return null;
}
-
- return false !== $file ? $file : null;
}
/**
diff --git a/plugins/woocommerce/tests/php/src/AutoloaderTest.php b/plugins/woocommerce/tests/php/src/AutoloaderTest.php
index 0663afbe996..a5ec4f587ed 100644
--- a/plugins/woocommerce/tests/php/src/AutoloaderTest.php
+++ b/plugins/woocommerce/tests/php/src/AutoloaderTest.php
@@ -9,6 +9,16 @@ use Composer\Autoload\ClassLoader;
/**
* Tests for the WooCommerce-scoped Composer PSR-4 fallback autoloader.
*
+ * Coverage boundary: register_woocommerce_psr4_fallback()'s decline-to-null path (a foreign-shaped
+ * probe loader) is pinned only at the helper level — see read_scoped_psr4_map() and
+ * read_scoped_file_path() below. It is not driven end-to-end through register_() because that would
+ * need two production testing seams on a bootstrap surface: register_() binds
+ * build_woocommerce_psr4_fallback() with early static binding (self::, so a subclass override won't
+ * dispatch) and memoizes its handler in a function-static that the woocommerce.php bootstrap already
+ * populates before any test runs (and PHP cannot reset a function-static). The helper tests guard
+ * the same null-returning logic; if the null-check wiring in register_() is ever refactored, add
+ * those seams to cover the contract end-to-end.
+ *
* @package Automattic\WooCommerce\Tests
*/
class AutoloaderTest extends \WC_Unit_Test_Case {
@@ -522,4 +532,124 @@ class AutoloaderTest extends \WC_Unit_Test_Case {
'The registered handler must be present on the SPL stack.'
);
}
+
+ /**
+ * `ClassLoader::getPrefixesPsr4()` carries no return-type declaration, so an older or foreign
+ * `Composer\Autoload\ClassLoader` reused from another path may return a non-array. The reader
+ * must degrade that shape to null, because the map flows straight into find_scoped_file()'s
+ * `array $psr4_entries` parameter on every autoload miss — outside the handler's try/catch — so
+ * a non-array would raise an uncatchable TypeError and fatal the request.
+ *
+ * @testdox read_scoped_psr4_map() degrades to null on a foreign non-array shape.
+ */
+ public function test_read_scoped_psr4_map_degrades_on_a_foreign_non_array_shape(): void {
+ // A foreign loader whose getPrefixesPsr4() returns a non-array (legal: the parent declares
+ // no return type). setPsr4() still works, so build() would treat it as a usable loader.
+ $foreign = new class() extends ClassLoader {
+ /**
+ * Model a foreign/ancient loader that returns a non-array prefix map.
+ *
+ * @return string A deliberately non-array value.
+ */
+ public function getPrefixesPsr4() {
+ return 'foreign-non-array-shape';
+ }
+ };
+
+ $this->assertNull(
+ Autoloader::read_scoped_psr4_map( $foreign ),
+ 'A non-array getPrefixesPsr4() return must degrade to null, not flow into find_scoped_file().'
+ );
+ }
+
+ /**
+ * A genuine loader returns its scoped PSR-4 map unchanged, so the handler keeps resolving
+ * src classes normally.
+ *
+ * @testdox read_scoped_psr4_map() returns the array map from a genuine loader.
+ */
+ public function test_read_scoped_psr4_map_returns_the_array_map_from_a_genuine_loader(): void {
+ $loader = new ClassLoader();
+ $loader->setPsr4( 'Automattic\\WooCommerce\\', array( dirname( WC_PLUGIN_FILE ) . '/src' ) );
+
+ $map = Autoloader::read_scoped_psr4_map( $loader );
+
+ $this->assertIsArray( $map, 'A genuine loader must yield an array map.' );
+ $this->assertArrayHasKey(
+ 'Automattic\\WooCommerce\\',
+ $map,
+ 'The scoped first-party prefix must be present in the returned map.'
+ );
+ }
+
+ /**
+ * `ClassLoader::findFile()` also carries no return-type declaration, so the same older or foreign
+ * `Composer\Autoload\ClassLoader` reused from another path may return a non-string. The reader
+ * must degrade that shape to null, because find_scoped_file() declares a `: ?string` return and
+ * the value reaches it on every autoload miss — outside the handler's try/catch — so a non-string
+ * would raise an uncatchable TypeError and fatal the request.
+ *
+ * @testdox read_scoped_file_path() degrades to null on a foreign non-string shape.
+ */
+ public function test_read_scoped_file_path_degrades_on_a_foreign_non_string_shape(): void {
+ // A foreign loader whose findFile() returns a non-array, non-string value (legal: the parent
+ // declares no return type).
+ $foreign = new class() extends ClassLoader {
+ /**
+ * Model a foreign/ancient loader that returns a non-string from findFile().
+ *
+ * @param string $class_name The probed class name (ignored).
+ *
+ * @return array<int, string> A deliberately non-string value.
+ */
+ public function findFile( $class_name ) {
+ // Avoid parameter not used PHPCS errors.
+ unset( $class_name );
+ return array( 'foreign-non-string-shape' );
+ }
+ };
+
+ $this->assertNull(
+ Autoloader::read_scoped_file_path( $foreign, 'Automattic\\WooCommerce\\Enums\\DefaultCustomerAddress' ),
+ 'A non-string findFile() return must degrade to null, not flow into find_scoped_file()\'s ?string return.'
+ );
+ }
+
+ /**
+ * A genuine loader still resolves a real src class to its file path, so normal resolution is
+ * unaffected.
+ *
+ * @testdox read_scoped_file_path() returns the resolved path from a genuine loader.
+ */
+ public function test_read_scoped_file_path_returns_the_path_from_a_genuine_loader(): void {
+ $loader = new ClassLoader();
+ $loader->setPsr4( 'Automattic\\WooCommerce\\', array( dirname( WC_PLUGIN_FILE ) . '/src' ) );
+
+ $path = Autoloader::read_scoped_file_path( $loader, 'Automattic\\WooCommerce\\Enums\\DefaultCustomerAddress' );
+
+ $this->assertIsString( $path, 'A genuine loader must resolve a real src class to a string path.' );
+ $this->assertStringContainsString(
+ 'DefaultCustomerAddress',
+ $path,
+ 'The resolved path must point at the probed class file.'
+ );
+ }
+
+ /**
+ * Composer's findFile() returns the literal `false` on a miss — the most common non-string
+ * return, since every class miss flows through it. read_scoped_file_path() must promote that
+ * `false` to null (is_string() excludes it), because that promotion is exactly what stops a
+ * TypeError at find_scoped_file()'s `?string` return on the most-travelled path.
+ *
+ * @testdox read_scoped_file_path() promotes findFile()'s false miss to null.
+ */
+ public function test_read_scoped_file_path_promotes_a_false_miss_to_null(): void {
+ $loader = new ClassLoader();
+ $loader->setPsr4( 'Automattic\\WooCommerce\\', array( dirname( WC_PLUGIN_FILE ) . '/src' ) );
+
+ $this->assertNull(
+ Autoloader::read_scoped_file_path( $loader, 'Automattic\\WooCommerce\\Nope\\DoesNotExistXYZ' ),
+ 'A findFile() miss (false) must promote to null, not surface as a non-string.'
+ );
+ }
}