diff --git a/inc/default-repo/namespace.php b/inc/default-repo/namespace.php index 7cf89b7e..49b3e420 100644 --- a/inc/default-repo/namespace.php +++ b/inc/default-repo/namespace.php @@ -26,7 +26,7 @@ function bootstrap() { */ function get_default_repo_domain() : string { if ( defined( 'FAIR_DEFAULT_REPO_DOMAIN' ) ) { - return FAIR_DEFAULT_REPO_DOMAIN; + return (string) \FAIR_DEFAULT_REPO_DOMAIN; } return 'api.aspirecloud.net'; diff --git a/inc/packages/namespace.php b/inc/packages/namespace.php index 28ee8886..874d96c8 100644 --- a/inc/packages/namespace.php +++ b/inc/packages/namespace.php @@ -680,28 +680,27 @@ function get_banners( $banners ) : array { } /** - * Get hashed file name from MetadataDocument. + * Get hashed slug from MetadataDocument. * * @param MetadataDocument $metadata MetadataDocument. * * @return string */ -function get_hashed_filename( $metadata ) : string { - $filename = $metadata->filename; - $type = str_replace( 'wp-', '', $metadata->type ); +function get_hashed_slug( $metadata ) : string { $did_hash = '-' . get_did_hash( $metadata->id ); - list( $slug, $file ) = explode( '/', $filename, 2 ); - if ( 'plugin' === $type ) { - if ( ! str_contains( $slug, $did_hash ) ) { - $slug .= $did_hash; - } - $filename = $slug . '/' . $file; - } else { - $filename = $slug . $did_hash; + // Use the slug from the filename, if present. + list( $slug ) = array_pad( explode( '/', $metadata->filename ?? '', 2 ), 1, '' ); + if ( '' === $slug ) { + $slug = $metadata->slug ?? ''; } - return $filename; + // Append DID hash to slug if not already present. + if ( ! str_ends_with( $slug, $did_hash ) ) { + $slug .= $did_hash; + } + + return $slug; } /** @@ -722,9 +721,6 @@ function get_package_data( $did ) { } $required_versions = version_requirements( $release ); - $filename = get_hashed_filename( $metadata ); - $type = str_replace( 'wp-', '', $metadata->type ); - $sections = (array) $metadata->sections; $description = trim( $sections['description'] ?? '' ); $response = [ @@ -732,11 +728,9 @@ function get_package_data( $did ) { 'author' => $metadata->authors[0]->name, 'author_uri' => $metadata->authors[0]->url, 'slug' => $metadata->slug, - 'slug_didhash' => $metadata->slug . '-' . get_did_hash( $did ), - $type => $filename, - 'file' => $filename, + 'slug_didhash' => get_hashed_slug( $metadata ), 'url' => $metadata->url ?? $metadata->slug, - 'sections' => $sections, + 'sections' => (array) $metadata->sections, 'description' => $description, 'short_description' => substr( strip_tags( $description ), 0, 147 ) . '...', 'icons' => isset( $release->artifacts->icon ) ? get_icons( $release->artifacts->icon ) : [], @@ -757,7 +751,8 @@ function get_package_data( $did ) { 'active_installs' => 0, '_fair' => $metadata, ]; - if ( 'theme' === $type ) { + + if ( 'wp-theme' === $metadata->type ) { $response['theme_uri'] = $response['url']; } @@ -865,7 +860,7 @@ function maybe_rename_on_package_download( $source, string $remote_source, WP_Up return $source; } - $new_source = trailingslashit( $remote_source ) . $metadata->slug . '-' . get_did_hash( $did ); + $new_source = trailingslashit( $remote_source ) . get_hashed_slug( $metadata ); if ( trailingslashit( strtolower( $source ) ) !== trailingslashit( strtolower( $new_source ) ) ) { $wp_filesystem->move( $source, $new_source, true ); @@ -917,7 +912,7 @@ function move_package_during_install( $source, string $remote_source, WP_Upgrade // Cannot guarantee a slug-didhash format. dir-didhash is the best achievable. $new_source = untrailingslashit( $source ) . "-{$did_hash}/"; } else { - $new_source = dirname( untrailingslashit( $source ), 2 ) . "/{$metadata->slug}-{$did_hash}/"; + $new_source = dirname( untrailingslashit( $source ), 2 ) . '/' . get_hashed_slug( $metadata ) . '/'; } // Core must be able to find the new source directory. diff --git a/inc/packages/wp-cli/compat/namespace.php b/inc/packages/wp-cli/compat/namespace.php index a625b8cb..c442e77b 100644 --- a/inc/packages/wp-cli/compat/namespace.php +++ b/inc/packages/wp-cli/compat/namespace.php @@ -8,6 +8,7 @@ namespace FAIR\Packages\WP_CLI\Compat; use FAIR\Packages as Packages; +use function FAIR\Updater\get_packages; use function WP_CLI\Utils\get_flag_value as get_flag_value; use WP_CLI; @@ -89,13 +90,6 @@ function ( $did ) { * @return void */ function handle_command( string $command, string $subcommand, array $args, array $assoc_args, array $items, array $dids ): void { - $hashed_items = replace_dids_with_hashed_filenames( $items, $dids ); - if ( $hashed_items === array_values( $items ) ) { - return; - } - - force_detection_by_did( $dids ); - switch ( $subcommand ) { case 'activate': case 'deactivate': @@ -107,7 +101,37 @@ function handle_command( string $command, string $subcommand, array $args, array case 'toggle': case 'uninstall': case 'update': - $args = array_merge( [ $command, $subcommand ], $hashed_items ); + $packages = get_packages(); + $plugin_basename_by_did = []; + + // Get the plugin basenames for the DIDs. + foreach ( $dids as $did ) { + if ( ! empty( $packages['plugins'][ $did ] ) ) { + $plugin_basename_by_did[ $did ] = plugin_basename( $packages['plugins'][ $did ] ); + } + } + + // Replace positional DIDs with plugin basenames where possible. + $plugin_items = array_map( + fn ( $item ) => $plugin_basename_by_did[ $item ] ?? $item, + $items + ); + + // Match DIDs to plugin information too. + add_filter( + 'all_plugins', + function ( $all_plugins ) use ( $plugin_basename_by_did ) { + foreach ( $plugin_basename_by_did as $did => $plugin_file ) { + if ( isset( $all_plugins[ $plugin_file ] ) ) { + $all_plugins[ $did ] = $all_plugins[ $plugin_file ]; + } + } + + return $all_plugins; + } + ); + + $args = array_merge( [ $command, $subcommand ], $plugin_items ); run_command_and_halt( $args, $assoc_args ); break; case 'search': @@ -127,49 +151,9 @@ function handle_command( string $command, string $subcommand, array $args, array WP_CLI::log( __( 'The verify-checksums command is not currently supported for DIDs.', 'fair' ) ); WP_CLI::halt( 1 ); break; - default: - // Do nothing. - break; } } -/** - * Force WP to detect plugins by their DIDs. - * - * This adds a filter to 'all_plugins' that duplicates entries - * for the hashed filenames to also be accessible by their DIDs. - * - * @param string[] $dids The DIDs to force detection for. - * @return void - */ -function force_detection_by_did( array $dids ): void { - add_filter( - 'all_plugins', - function ( $all_plugins ) use ( $dids ) { - foreach ( $dids as $did ) { - $metadata = Packages\fetch_package_metadata( $did ); - if ( is_wp_error( $metadata ) ) { - WP_CLI::warning( - sprintf( - /* translators: 1: The DID, 2: The error message. */ - __( 'Could not retrieve metadata for %1$s - %2$s', 'fair' ), - $did, - $metadata->get_error_message() - ) - ); - continue; - } - - $filename = Packages\get_hashed_filename( $metadata ); - if ( isset( $all_plugins[ $filename ] ) ) { - $all_plugins[ $did ] = $all_plugins[ $filename ]; - } - } - return $all_plugins; - } - ); -} - /** * Prime the environment for the search command. * @@ -258,35 +242,3 @@ function run_command_and_halt( array $args, array $assoc_args = [] ): void { } } } - -/** - * Replace DIDs in an array of items with their hashed filenames. - * - * @param string[] $items The command line items. - * @param string[] $dids The DIDs to replace. - * @return string[] The modified items. - */ -function replace_dids_with_hashed_filenames( array $items, array $dids ): array { - return array_map( - function ( $item ) use ( $dids ) { - if ( in_array( $item, $dids, true ) ) { - $metadata = Packages\fetch_package_metadata( $item ); - if ( is_wp_error( $metadata ) ) { - WP_CLI::warning( - sprintf( - /* translators: 1: The DID, 2: The error message. */ - __( 'Could not retrieve metadata for %1$s - %2$s', 'fair' ), - $item, - $metadata->get_error_message() - ) - ); - return $item; - } - - return Packages\get_hashed_filename( $metadata ); - } - return $item; - }, - $items - ); -} diff --git a/inc/updater/class-updater.php b/inc/updater/class-updater.php index d822d100..44dc0fe5 100644 --- a/inc/updater/class-updater.php +++ b/inc/updater/class-updater.php @@ -279,8 +279,11 @@ private static function update_site_transient( $transient, array $packages ) { } $rel_path = $package->get_relative_path(); + $meta = $package->get_metadata(); + $package_type = str_replace( 'wp-', '', $meta->type ); $response['slug'] = $response['slug_didhash']; + $response[ $package_type ] = $rel_path; $is_compatible = Packages\check_requirements( $release ); diff --git a/tests/phpstan-baseline.neon b/tests/phpstan-baseline.neon index 7bacc7c8..5fa1d3bb 100644 --- a/tests/phpstan-baseline.neon +++ b/tests/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - message: '#^Call to static method warning\(\) on an unknown class WP_CLI\.$#' identifier: class.notFound - count: 3 + count: 1 path: ../inc/packages/wp-cli/compat/namespace.php - diff --git a/tests/phpunit/tests/Packages/GetHashedPackageIdentityTest.php b/tests/phpunit/tests/Packages/GetHashedPackageIdentityTest.php new file mode 100644 index 00000000..e95f49a7 --- /dev/null +++ b/tests/phpunit/tests/Packages/GetHashedPackageIdentityTest.php @@ -0,0 +1,178 @@ +create_metadata_document( + [ + 'filename' => 'example/example.php', + 'slug' => 'example', + 'type' => 'wp-plugin', + ] + ); + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Test that plugin slugs hash independently of the bootstrap filename. + */ + public function test_should_ignore_plugin_bootstrap_filename_when_hashing_slug() { + $metadata = $this->create_metadata_document( + [ + 'filename' => 'example/custom-bootstrap.php', + 'slug' => 'example', + 'type' => 'wp-plugin', + ] + ); + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Test that malformed plugin filenames still produce a valid hashed slug. + */ + public function test_should_recover_when_plugin_filename_has_no_main_file() { + $metadata = $this->create_metadata_document( + [ + 'filename' => 'example', + 'slug' => 'example', + 'type' => 'wp-plugin', + ] + ); + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Test that theme slugs append the DID hash. + */ + public function test_should_hash_theme_slug() { + $metadata = $this->create_metadata_document( + [ + 'filename' => 'example', + 'slug' => 'example', + 'type' => 'wp-theme', + ] + ); + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Test that missing non-plugin filenames still fall back to the slug. + */ + public function test_should_fall_back_to_slug_for_non_plugin_when_filename_missing() { + $metadata = $this->create_metadata_document( + [ + 'filename' => null, + 'slug' => 'example', + 'type' => 'wp-theme', + ] + ); + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Test that a pre-hashed plugin slug is not hashed twice. + */ + public function test_should_not_append_hash_twice_for_plugin_slug() { + $hash = get_did_hash( 'did:plc:example1234567890123456789' ); + $metadata = $this->create_metadata_document( + [ + 'filename' => 'example-' . $hash . '/example.php', + 'slug' => 'example-' . $hash, + 'type' => 'wp-plugin', + ] + ); + + $this->assertSame( 'example-' . $hash, get_hashed_slug( $metadata ) ); + } + + /** + * Test that a hash-like substring in the middle of the slug still gets the DID hash appended. + */ + public function test_should_append_hash_when_same_value_appears_in_middle_of_plugin_slug() { + $hash = get_did_hash( 'did:plc:example1234567890123456789' ); + $slug = 'vendor-' . $hash . '-plugin'; + $metadata = $this->create_metadata_document( + [ + 'filename' => $slug . '/' . $slug . '.php', + 'slug' => $slug, + 'type' => 'wp-plugin', + ] + ); + + $this->assertSame( $slug . '-' . $hash, get_hashed_slug( $metadata ) ); + } + + /** + * Test that empty plugin filenames behave the same as missing filenames for slug hashing. + */ + public function test_should_fall_back_to_slug_when_plugin_filename_is_empty_string() { + $metadata = $this->create_metadata_document( + [ + 'filename' => '', + 'slug' => 'example', + 'type' => 'wp-plugin', + ] + ); + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Test that a missing type is treated as non-plugin metadata. + * + * This covers the operator precedence difference between + * `'wp-plugin' === ( $metadata->type ?? '' )` and + * `'wp-plugin' === $metadata->type ?? ''`. + */ + public function test_should_treat_missing_type_as_non_plugin_metadata() { + $metadata = (object) [ + 'id' => 'did:plc:example1234567890123456789', + 'slug' => 'example', + 'filename' => 'example/example.php', + ]; + + $this->assertSame( 'example-' . get_did_hash( $metadata->id ), get_hashed_slug( $metadata ) ); + } + + /** + * Create a metadata document for testing. + * + * @param array $overrides Document overrides. + * @return MetadataDocument + */ + private function create_metadata_document( array $overrides ) : MetadataDocument { + $metadata = new MetadataDocument(); + $metadata->id = 'did:plc:example1234567890123456789'; + $metadata->type = 'wp-plugin'; + $metadata->slug = 'example'; + $metadata->filename = 'example/example.php'; + + foreach ( $overrides as $key => $value ) { + $metadata->{$key} = $value; + } + + return $metadata; + } +}