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.'
+		);
+	}
 }