Commit ddd2784d88e for woocommerce
commit ddd2784d88e95937171f4b5df89e7f729a9cdb6f
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Wed Jun 17 15:19:13 2026 +0200
Fix hardcoded wp-content paths (#65599)
diff --git a/plugins/woocommerce/changelog/fix-hardcoded-wp-content-paths b/plugins/woocommerce/changelog/fix-hardcoded-wp-content-paths
new file mode 100644
index 00000000000..9a37d18e132
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hardcoded-wp-content-paths
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Resolve downloadable file paths and Mini Cart lazy-loaded script URLs against the configured content directory and site URL instead of a hardcoded /wp-content location, so they work on non-default WordPress directory layouts (e.g. Bedrock or a custom WP_CONTENT_DIR).
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index 2344659a667..2db60ad25cb 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -10,6 +10,8 @@
defined( 'ABSPATH' ) || exit;
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
+
/**
* Download handler class.
*/
@@ -310,9 +312,7 @@ class WC_Download_Handler {
);
}
- $wp_content_dirname = ( 0 === strpos( WP_CONTENT_DIR, ABSPATH ) )
- ? '/' . substr( WP_CONTENT_DIR, strlen( ABSPATH ) )
- : '/wp-content';
+ $wp_content_dirname = FilesystemUtil::get_content_directory_relative_path();
// See if path needs an abspath prepended to work.
if ( file_exists( ABSPATH . $file_path ) ) {
diff --git a/plugins/woocommerce/includes/class-wc-product-download.php b/plugins/woocommerce/includes/class-wc-product-download.php
index 6f52ff7aff3..afa5e7d1a37 100644
--- a/plugins/woocommerce/includes/class-wc-product-download.php
+++ b/plugins/woocommerce/includes/class-wc-product-download.php
@@ -9,6 +9,7 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Automattic\WooCommerce\Internal\Utilities\URL;
defined( 'ABSPATH' ) || exit;
@@ -187,15 +188,43 @@ class WC_Product_Download implements ArrayAccess {
if ( 'relative' !== $this->get_type_of_file_path() ) {
return true;
}
- $file_url = $this->get_file();
- if ( '..' === substr( $file_url, 0, 2 ) || '/' !== substr( $file_url, 0, 1 ) ) {
- $file_url = realpath( ABSPATH . $file_url );
- } elseif ( substr( WP_CONTENT_DIR, strlen( untrailingslashit( ABSPATH ) ) ) === substr( $file_url, 0, strlen( substr( WP_CONTENT_DIR, strlen( untrailingslashit( ABSPATH ) ) ) ) ) ) {
- $file_url = realpath( WP_CONTENT_DIR . substr( $file_url, 11 ) );
- }
+
+ $file_url = $this->get_absolute_file_path( $this->get_file() );
+
return apply_filters( 'woocommerce_downloadable_file_exists', file_exists( $file_url ), $this->get_file() );
}
+ /**
+ * Resolve a stored "relative" download file reference to an absolute filesystem path.
+ *
+ * A relative reference can be expressed either against the WordPress root (ABSPATH)
+ * or as a root-relative path into the content directory; this resolves both, honoring
+ * a relocated WP_CONTENT_DIR.
+ *
+ * @param string $file_url The stored file reference, of "relative" type.
+ * @return string|false The absolute filesystem path, the reference unchanged, or false if realpath() fails.
+ */
+ private function get_absolute_file_path( $file_url ) {
+ // Paths starting with ".." or not starting with "/" are taken relative to ABSPATH.
+ $is_abspath_relative = '..' === substr( $file_url, 0, 2 ) || '/' !== substr( $file_url, 0, 1 );
+ if ( $is_abspath_relative ) {
+ return realpath( ABSPATH . $file_url );
+ }
+
+ // Root-relative paths into the content directory are resolved against WP_CONTENT_DIR.
+ // Match the content dirname only at a path-segment boundary, so a content dir such as
+ // "/app" does not false-match an unrelated path like "/application/...".
+ $content_dirname = FilesystemUtil::get_content_directory_relative_path();
+ $is_content_relative = $file_url === $content_dirname || 0 === strpos( $file_url, trailingslashit( $content_dirname ) );
+ if ( $is_content_relative ) {
+ $path_within_content_dir = substr( $file_url, strlen( $content_dirname ) );
+ return realpath( WP_CONTENT_DIR . $path_within_content_dir );
+ }
+
+ // Not an ABSPATH- or content-relative path; leave it untouched.
+ return $file_url;
+ }
+
/**
* Confirms that the download exists within an approved directory.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 461b2aab69d..76da6d342e0 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -49557,30 +49557,12 @@ parameters:
count: 1
path: src/Admin/WCAdminHelper.php
- -
- message: '#^Method Automattic\\WooCommerce\\Admin\\WCAdminHelper\:\:get_normalized_url_path\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Admin/WCAdminHelper.php
-
-
message: '#^Method Automattic\\WooCommerce\\Admin\\WCAdminHelper\:\:get_url_from_wp\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Admin/WCAdminHelper.php
- -
- message: '#^Parameter \#1 \$string of function strlen expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: src/Admin/WCAdminHelper.php
-
- -
- message: '#^Static method Automattic\\WooCommerce\\Admin\\WCAdminHelper\:\:get_normalized_url_path\(\) is unused\.$#'
- identifier: method.unused
- count: 1
- path: src/Admin/WCAdminHelper.php
-
-
message: '#^Method Automattic\\WooCommerce\\Autoloader\:\:missing_autoloader\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -49815,12 +49797,6 @@ parameters:
count: 1
path: src/Blocks/AssetsController.php
- -
- message: '#^Parameter \#1 \$src of method Automattic\\WooCommerce\\Blocks\\AssetsController\:\:get_absolute_url\(\) expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: src/Blocks/AssetsController.php
-
-
message: '#^Parameter \#1 \$text of function esc_js expects string, int\<1, max\> given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Admin/WCAdminHelper.php b/plugins/woocommerce/src/Admin/WCAdminHelper.php
index b6c88709dbe..84c3172a085 100644
--- a/plugins/woocommerce/src/Admin/WCAdminHelper.php
+++ b/plugins/woocommerce/src/Admin/WCAdminHelper.php
@@ -197,26 +197,6 @@ class WCAdminHelper {
return self::is_current_page_store_page();
}
- /**
- * Get normalized URL path.
- * 1. Only keep the path and query string (if any).
- * 2. Remove wp home path from the URL path if WP is installed in a subdirectory.
- * 3. Remove leading and trailing slashes.
- *
- * For example:
- *
- * - https://example.com/wordpress/shop/uncategorized/test/?add-to-cart=123 => shop/uncategorized/test/?add-to-cart=123
- *
- * @param string $url URL to normalize.
- */
- private static function get_normalized_url_path( $url ) {
- $query = wp_parse_url( $url, PHP_URL_QUERY );
- $path = wp_parse_url( $url, PHP_URL_PATH ) . ( $query ? '?' . $query : '' );
- $home_path = wp_parse_url( site_url(), PHP_URL_PATH ) ?? '';
- $normalized_path = trim( substr( $path, strlen( $home_path ) ), '/' );
- return $normalized_path;
- }
-
/**
* Builds the relative URL from the WP instance.
*
diff --git a/plugins/woocommerce/src/Blocks/AssetsController.php b/plugins/woocommerce/src/Blocks/AssetsController.php
index ce6f99a1736..6fb44902d90 100644
--- a/plugins/woocommerce/src/Blocks/AssetsController.php
+++ b/plugins/woocommerce/src/Blocks/AssetsController.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
+use Automattic\WooCommerce\Blocks\Utils\Utils;
/**
* AssetsController class.
@@ -328,7 +329,12 @@ final class AssetsController {
$src = array();
foreach ( $found_dependencies as $handle => $unused ) {
- $src[] = esc_url( add_query_arg( 'ver', $wp_scripts->registered[ $handle ]->ver, $this->get_absolute_url( $wp_scripts->registered[ $handle ]->src ) ) );
+ $script_src = $wp_scripts->registered[ $handle ]->src;
+ if ( ! is_string( $script_src ) ) {
+ // Skip srcless dependencies (e.g. meta-packages), which have no URL to hint.
+ continue;
+ }
+ $src[] = esc_url( add_query_arg( 'ver', $wp_scripts->registered[ $handle ]->ver, Utils::get_absolute_script_url( $script_src ) ) );
}
return $src;
}
@@ -357,20 +363,6 @@ final class AssetsController {
}
}
- /**
- * Returns an absolute url to relative links for WordPress core scripts.
- *
- * @param string $src Original src that can be relative.
- * @return string Correct full path string.
- */
- private function get_absolute_url( $src ) {
- $wp_scripts = wp_scripts();
- if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && 0 === strpos( $src, $wp_scripts->content_url ) ) ) {
- $src = $wp_scripts->base_url . $src;
- }
- return $src;
- }
-
/**
* Skip Jetpack Boost minification on older versions of Jetpack Boost where it causes issues.
*
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
index 86c94d2f104..e914481128d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
@@ -412,8 +412,6 @@ class MiniCart extends AbstractBlock {
return;
}
- $site_url = site_url() ?? wp_guess_url();
-
if ( Utils::wp_version_compare( '6.3', '>=' ) ) {
$script_before = $wp_scripts->get_inline_script_data( $script->handle, 'before' );
$script_after = $wp_scripts->get_inline_script_data( $script->handle, 'after' );
@@ -423,7 +421,7 @@ class MiniCart extends AbstractBlock {
}
$this->scripts_to_lazy_load[ $script->handle ] = array(
- 'src' => preg_match( '|^(https?:)?//|', $script->src ) ? $script->src : $site_url . $script->src,
+ 'src' => Utils::get_absolute_script_url( $script->src ),
'version' => $script->ver,
'before' => $script_before,
'after' => $script_after,
diff --git a/plugins/woocommerce/src/Blocks/DependencyDetection.php b/plugins/woocommerce/src/Blocks/DependencyDetection.php
index d5c24f51a95..719517b9dbb 100644
--- a/plugins/woocommerce/src/Blocks/DependencyDetection.php
+++ b/plugins/woocommerce/src/Blocks/DependencyDetection.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks;
+use Automattic\WooCommerce\Blocks\Utils\Utils;
use Automattic\WooCommerce\Internal\Utilities\BlocksUtil;
/**
@@ -194,10 +195,8 @@ final class DependencyDetection {
// Skip malformed src.
continue;
}
- if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
- // Relative URL - make it absolute.
- $src = $wp_scripts->base_url . $src;
- }
+ // Resolve relative URLs to absolute the same way WordPress core does.
+ $src = Utils::get_absolute_script_url( $src );
// Skip WooCommerce's own scripts - we don't need to check those.
if ( $this->is_woocommerce_script( $src ) ) {
diff --git a/plugins/woocommerce/src/Blocks/Utils/Utils.php b/plugins/woocommerce/src/Blocks/Utils/Utils.php
index e0b4f0a033e..90cdb14adb4 100644
--- a/plugins/woocommerce/src/Blocks/Utils/Utils.php
+++ b/plugins/woocommerce/src/Blocks/Utils/Utils.php
@@ -1,6 +1,8 @@
<?php
namespace Automattic\WooCommerce\Blocks\Utils;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+
/**
* Utils class
*/
@@ -28,4 +30,23 @@ class Utils {
return version_compare( $current_wp_version, $version, $operator );
}
+
+ /**
+ * Resolve a (possibly relative) script src to an absolute URL the same way
+ * WordPress core does in WP_Scripts::do_item(): a relative src is resolved
+ * against the scripts base URL (the site URL), unless it already points at
+ * the content directory. This keeps the resulting URL consistent with what
+ * WordPress itself would emit, including on non-default directory layouts
+ * (e.g. a custom WP_CONTENT_DIR/WP_CONTENT_URL).
+ *
+ * @param string $src The script src, which may be relative or absolute.
+ * @return string The absolute script URL.
+ */
+ public static function get_absolute_script_url( $src ) {
+ $wp_scripts = wc_get_container()->get( LegacyProxy::class )->call_function( 'wp_scripts' );
+ if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && 0 === strpos( $src, $wp_scripts->content_url ) ) ) {
+ $src = $wp_scripts->base_url . $src;
+ }
+ return $src;
+ }
}
diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
index 7e154a91430..7776082f18d 100644
--- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
@@ -70,6 +70,33 @@ class FilesystemUtil {
return 'direct';
}
+ /**
+ * Get the content directory's root-relative path (e.g. "/wp-content").
+ *
+ * Resolves the path under which files inside the content directory are addressed,
+ * honoring a relocated WP_CONTENT_DIR. When the content directory lives under
+ * ABSPATH the path is derived from it directly; otherwise (e.g. Bedrock-style
+ * layouts where it sits outside ABSPATH) it is derived from the content URL,
+ * falling back to "/wp-content".
+ *
+ * @internal
+ *
+ * @since 11.0.0
+ *
+ * @return string The content directory's root-relative path, e.g. "/wp-content" or "/app".
+ */
+ public static function get_content_directory_relative_path(): string {
+ $abspath = (string) Constants::get_constant( 'ABSPATH' );
+ $wp_content_dir = (string) Constants::get_constant( 'WP_CONTENT_DIR' );
+
+ if ( '' !== $abspath && 0 === strpos( $wp_content_dir, $abspath ) ) {
+ return '/' . substr( $wp_content_dir, strlen( $abspath ) );
+ }
+
+ $content_url_path = wp_parse_url( content_url(), PHP_URL_PATH );
+ return is_string( $content_url_path ) ? $content_url_path : '/wp-content';
+ }
+
/**
* Check if a constant exists and is not null.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-downloads-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-downloads-test.php
index 89d6e1a3dac..03464f72bb9 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-downloads-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-downloads-test.php
@@ -183,4 +183,37 @@ class WC_Product_Download_Test extends WC_Unit_Test_Case {
'We use the same error message when the file does not exist as when the directory is invalid.'
);
}
+
+ /**
+ * @testdox get_absolute_file_path() resolves real content-dir paths but matches the content dir only at a path-segment boundary.
+ */
+ public function test_get_absolute_file_path_matches_content_dir_only_at_a_boundary() {
+ $download = new WC_Product_Download();
+
+ // get_absolute_file_path() is private and reads the raw WP_CONTENT_DIR/ABSPATH constants,
+ // so we invoke it directly to assert its boundary matching deterministically.
+ $resolve = new ReflectionMethod( WC_Product_Download::class, 'get_absolute_file_path' );
+ $resolve->setAccessible( true );
+
+ $content_name = wp_basename( WP_CONTENT_DIR );
+
+ // A genuine path into the content directory is treated as content-relative: it is resolved
+ // against WP_CONTENT_DIR (realpath() of a non-existent file yields false), not returned as-is.
+ $content_path = '/' . $content_name . '/uploads/does-not-exist.zip';
+ $this->assertNotSame(
+ $content_path,
+ $resolve->invoke( $download, $content_path ),
+ 'A genuine content-directory path should be resolved against WP_CONTENT_DIR, not returned unchanged.'
+ );
+
+ // A sibling whose name merely starts with the content-dir name (e.g. "/app" vs "/application")
+ // must NOT be treated as content-relative; the reference is returned unchanged rather than being
+ // mis-mapped under WP_CONTENT_DIR.
+ $prefix_sibling = '/' . $content_name . 'X/decoy.zip';
+ $this->assertSame(
+ $prefix_sibling,
+ $resolve->invoke( $download, $prefix_sibling ),
+ 'A path that only shares the content-dir name prefix must not be mapped under WP_CONTENT_DIR.'
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/UtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/UtilsTest.php
new file mode 100644
index 00000000000..369cc8b55b5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/UtilsTest.php
@@ -0,0 +1,153 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Utils;
+
+use Automattic\WooCommerce\Blocks\Utils\Utils;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Blocks Utils class.
+ */
+class UtilsTest extends WC_Unit_Test_Case {
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->reset_legacy_proxy_mocks();
+ parent::tearDown();
+ }
+
+ /**
+ * Make wp_scripts() return a stand-in exposing the given base and content URLs.
+ *
+ * @param string $base_url The value for WP_Scripts::$base_url.
+ * @param string $content_url The value for WP_Scripts::$content_url.
+ */
+ private function mock_wp_scripts( string $base_url, string $content_url ): void {
+ $wp_scripts = (object) array(
+ 'base_url' => $base_url,
+ 'content_url' => $content_url,
+ );
+
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_scripts' => function () use ( $wp_scripts ) {
+ return $wp_scripts;
+ },
+ )
+ );
+ }
+
+ /**
+ * @testdox get_absolute_script_url() resolves a script src the same way WP_Scripts::do_item() would.
+ * @dataProvider provider_script_urls
+ *
+ * @param string $content_url The WP_Scripts content URL.
+ * @param string $src The script src to resolve.
+ * @param string $expected The expected resolved URL.
+ */
+ public function test_get_absolute_script_url( string $content_url, string $src, string $expected ): void {
+ $this->mock_wp_scripts( 'https://example.com/wp', $content_url );
+
+ $this->assertSame( $expected, Utils::get_absolute_script_url( $src ) );
+ }
+
+ /**
+ * Data provider of script srcs and their expected resolved URLs (base URL = https://example.com/wp).
+ *
+ * @return array<array<string>>
+ */
+ public function provider_script_urls(): array {
+ $abs_content = 'https://example.com/wp-content';
+
+ return array(
+ // Absolute https URL is unchanged.
+ array( $abs_content, 'https://cdn.test/a.js', 'https://cdn.test/a.js' ),
+ // Absolute http URL is unchanged.
+ array( $abs_content, 'http://cdn.test/a.js', 'http://cdn.test/a.js' ),
+ // Protocol-relative URL is unchanged.
+ array( $abs_content, '//cdn.test/a.js', '//cdn.test/a.js' ),
+ // Root-relative path gets the base URL prepended.
+ array( $abs_content, '/wp-includes/a.js', 'https://example.com/wp/wp-includes/a.js' ),
+ // Relative path gets the base URL prepended.
+ array( $abs_content, 'wp-includes/a.js', 'https://example.com/wpwp-includes/a.js' ),
+ // Absolute content URL is unchanged.
+ array( $abs_content, 'https://example.com/wp-content/x.js', 'https://example.com/wp-content/x.js' ),
+ // An empty src is a degenerate input (callers normally guard against it) and yields the base URL.
+ array( $abs_content, '', 'https://example.com/wp' ),
+ // Empty content URL still prepends the base URL.
+ array( '', '/wp-includes/a.js', 'https://example.com/wp/wp-includes/a.js' ),
+ // With a relative content URL, a content-dir-prefixed src is left untouched (the content_url short-circuit).
+ array( '/wp-content', '/wp-content/plugins/x.js', '/wp-content/plugins/x.js' ),
+ // A name-prefix sibling ("/wp-contentX") also matches the content_url prefix and is left untouched,
+ // mirroring WP_Scripts::do_item()'s str_starts_with() check (a plain prefix, with no segment boundary).
+ array( '/wp-content', '/wp-contentX/plugin.js', '/wp-contentX/plugin.js' ),
+ // A non-content relative src still gets the base URL prepended.
+ array( '/wp-content', '/wp-includes/x.js', 'https://example.com/wp/wp-includes/x.js' ),
+ );
+ }
+
+ /**
+ * @testdox get_absolute_script_url() reproduces the behavior of the code it replaced.
+ * @dataProvider provider_legacy_srcs
+ *
+ * @param string $content_url The WP_Scripts content URL.
+ * @param string $src The script src to resolve.
+ * @param bool $diverges_from_simple_variant Whether the result is expected to diverge from the simpler MiniCart/DependencyDetection variant.
+ */
+ public function test_get_absolute_script_url_matches_replaced_code( string $content_url, string $src, bool $diverges_from_simple_variant ): void {
+ $base_url = 'https://example.com/wp';
+ $this->mock_wp_scripts( $base_url, $content_url );
+
+ $result = Utils::get_absolute_script_url( $src );
+
+ // Reference implementation of the removed AssetsController::get_absolute_url(), which is identical to
+ // WP_Scripts::do_item(). The helper must reproduce it for every input.
+ $assets_controller_legacy = $src;
+ if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $content_url && 0 === strpos( $src, $content_url ) ) ) {
+ $assets_controller_legacy = $base_url . $src;
+ }
+ $this->assertSame( $assets_controller_legacy, $result, 'Must match the replaced AssetsController::get_absolute_url() logic.' );
+
+ // Reference for the simpler MiniCart/DependencyDetection variant (no content_url short-circuit; their
+ // base URL, site_url(), equals WP_Scripts::$base_url). The helper matches it everywhere except for a
+ // content-dir-prefixed src under a relative content URL, where it intentionally skips the prepend.
+ $simple_variant = preg_match( '|^(https?:)?//|', $src ) ? $src : $base_url . $src;
+ if ( $diverges_from_simple_variant ) {
+ $this->assertNotSame( $simple_variant, $result, 'Should diverge from the simpler variant only for content-dir-prefixed srcs.' );
+ } else {
+ $this->assertSame( $simple_variant, $result, 'Must match the replaced MiniCart/DependencyDetection logic.' );
+ }
+ }
+
+ /**
+ * Data provider of srcs covering every branch of the replaced logic.
+ *
+ * The third value is whether the result is expected to diverge from the simpler variant.
+ *
+ * @return array<array{string, string, bool}>
+ */
+ public function provider_legacy_srcs(): array {
+ return array(
+ // Absolute https URL.
+ array( 'https://example.com/wp-content', 'https://cdn.test/a.js', false ),
+ // Protocol-relative URL.
+ array( 'https://example.com/wp-content', '//cdn.test/a.js', false ),
+ // Root-relative path.
+ array( 'https://example.com/wp-content', '/wp-includes/a.js', false ),
+ // Relative path.
+ array( 'https://example.com/wp-content', 'wp-includes/a.js', false ),
+ // Content-dir-prefixed src under an absolute content URL (caught by the scheme check, not content_url).
+ array( 'https://example.com/wp-content', 'https://example.com/wp-content/x.js', false ),
+ // Content-dir-prefixed src under a relative content URL: the one case that diverges from the simpler variant.
+ array( '/wp-content', '/wp-content/plugins/x.js', true ),
+ // A name-prefix sibling ("/wp-contentX") also matches the content_url prefix (mirroring core), so it is
+ // left untouched and likewise diverges from the simpler base-URL-prepending variant.
+ array( '/wp-content', '/wp-contentX/plugin.js', true ),
+ // Non-content src under a relative content URL.
+ array( '/wp-content', '/wp-includes/x.js', false ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
index de2cc844a0a..260cf374884 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
@@ -288,6 +288,75 @@ class FilesystemUtilTest extends WC_Unit_Test_Case {
$wp_filesystem = $original_wp_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
+ /**
+ * @testdox get_content_directory_relative_path() derives the path from WP_CONTENT_DIR when it lives under ABSPATH.
+ * @dataProvider provider_content_dir_under_abspath
+ *
+ * @param string $abspath The ABSPATH value to use.
+ * @param string $wp_content_dir The WP_CONTENT_DIR value to use.
+ * @param string $expected The expected root-relative content path.
+ */
+ public function test_get_content_directory_relative_path_under_abspath( string $abspath, string $wp_content_dir, string $expected ): void {
+ Constants::set_constant( 'ABSPATH', $abspath );
+ Constants::set_constant( 'WP_CONTENT_DIR', $wp_content_dir );
+
+ $this->assertSame( $expected, FilesystemUtil::get_content_directory_relative_path() );
+ }
+
+ /**
+ * Data provider for content directories located under ABSPATH.
+ *
+ * @return array<array<string>>
+ */
+ public function provider_content_dir_under_abspath(): array {
+ return array(
+ // Default layout.
+ array( '/var/www/html/', '/var/www/html/wp-content', '/wp-content' ),
+ // Renamed content directory.
+ array( '/var/www/html/', '/var/www/html/custom-content', '/custom-content' ),
+ // Nested content directory.
+ array( '/var/www/html/', '/var/www/html/wp/content', '/wp/content' ),
+ // WordPress in a subdirectory.
+ array( '/var/www/html/wp/', '/var/www/html/wp/wp-content', '/wp-content' ),
+ );
+ }
+
+ /**
+ * @testdox get_content_directory_relative_path() falls back to the content URL path when WP_CONTENT_DIR is not under ABSPATH.
+ * @dataProvider provider_content_dir_outside_abspath
+ *
+ * @param string $abspath The ABSPATH value to use.
+ * @param string $wp_content_dir The WP_CONTENT_DIR value to use.
+ */
+ public function test_get_content_directory_relative_path_falls_back_to_content_url( string $abspath, string $wp_content_dir ): void {
+ Constants::set_constant( 'ABSPATH', $abspath );
+ Constants::set_constant( 'WP_CONTENT_DIR', $wp_content_dir );
+
+ // When the content directory is not under ABSPATH the path must come from the content URL,
+ // never from a bogus ABSPATH-relative substring of WP_CONTENT_DIR.
+ $expected = wp_parse_url( content_url(), PHP_URL_PATH );
+
+ $this->assertSame( $expected, FilesystemUtil::get_content_directory_relative_path() );
+ }
+
+ /**
+ * Data provider for content directories that are not located under ABSPATH.
+ *
+ * @return array<array<string>>
+ */
+ public function provider_content_dir_outside_abspath(): array {
+ return array(
+ // Bedrock-style sibling directory.
+ array( '/var/www/html/wp/', '/var/www/html/app' ),
+ // Sibling sharing a name prefix (must not false-match ABSPATH).
+ array( '/var/www/html/', '/var/www/htmlx/wp-content' ),
+ // Unrelated absolute path.
+ array( '/var/www/html/', '/totally/different/app' ),
+ // Empty ABSPATH.
+ array( '', '/var/www/html/wp-content' ),
+ );
+ }
+
/**
* @testdox 'mkdir_p_not_indexable' writes the expected .htaccess based on the allow_file_access flag.
*