|null */ private ?array $inventory = null; public function __construct( string $hash_key, string $audit_plugin, ?WordPressPathProvider $paths = null ) { if ( '' === $hash_key || '' === $audit_plugin ) { throw new InvalidArgumentException( 'invalid_vendor_scope' ); } $this->hash_key = $hash_key; $this->audit_plugin = plugin_basename( $audit_plugin ); $this->paths = $paths ?? new WordPressPathProvider(); } /** * Public inventory for the administrator selection screen. No paths are returned. * * @return list */ public function candidates(): array { return array_map( static fn ( array $candidate ): array => array( 'id' => $candidate['id'], 'type' => $candidate['type'], 'name' => $candidate['name'], 'version' => $candidate['version'], 'state' => $candidate['state'], 'available' => $candidate['available'], ), $this->inventory() ); } /** * Every currently eligible root is used only as the runtime path allowlist. * * @return list */ public function policy_seeds(): array { $seeds = array(); foreach ( $this->inventory() as $candidate ) { if ( $candidate['available'] && null !== $candidate['seed'] ) { $seeds[] = $candidate['seed']; } } return $seeds; } /** * Resolve exactly 1–10 current inventory IDs without accepting a path, alias, * wildcard, duplicate, unavailable component, or the audit plugin itself. * * @param array $selected Untrusted REST values. * @return list */ public function selected_seeds( array $selected ): array { if ( array() === $selected || count( $selected ) > self::MAX_SELECTION ) { throw new InvalidArgumentException( 'invalid_vendor_selection' ); } $available = array(); foreach ( $this->inventory() as $candidate ) { if ( $candidate['available'] && null !== $candidate['seed'] ) { $available[ $candidate['id'] ] = $candidate['seed']; } } $seeds = array(); $seen = array(); foreach ( $selected as $id ) { if ( ! is_string( $id ) || '' === $id || strlen( $id ) > 240 || isset( $seen[ $id ] ) || ! isset( $available[ $id ] ) ) { throw new InvalidArgumentException( 'invalid_vendor_selection' ); } $seen[ $id ] = true; $seeds[] = $available[ $id ]; } return $seeds; } /** * @return list */ private function inventory(): array { if ( null !== $this->inventory ) { return $this->inventory; } $this->inventory = array_merge( $this->plugins(), $this->themes() ); usort( $this->inventory, static fn ( array $left, array $right ): int => array( $left['type'], $left['name'], $left['id'] ) <=> array( $right['type'], $right['name'], $right['id'] ) ); return $this->inventory; } /** * @return list */ private function plugins(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once $this->paths->core_file( 'wp-admin/includes/plugin.php' ); } $plugins = get_plugins(); $active = array_fill_keys( array_map( 'strval', (array) get_option( 'active_plugins', array() ) ), true ); $network = is_multisite() ? array_fill_keys( array_map( 'strval', array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ), true ) : array(); $result = array(); foreach ( $plugins as $basename => $headers ) { $basename = (string) $basename; if ( $basename === $this->audit_plugin || 0 !== validate_file( $basename ) ) { continue; } $main_file = $this->paths->plugin_path( $basename ); $directory = dirname( $basename ); $path = '.' === $directory ? $main_file : $this->paths->plugin_path( $directory ); $seed = $this->seed( 'vendor_plugin', $path, '.' === $directory ? 'file' : 'directory', $this->paths->plugins_root() ); $result[] = array( 'id' => 'plugin:' . $basename, 'type' => 'plugin', 'name' => $this->plain_label( (string) ( $headers['Name'] ?? '' ), $basename ), 'version' => $this->plain_version( (string) ( $headers['Version'] ?? '' ) ), 'state' => isset( $network[ $basename ] ) ? 'network-active' : ( isset( $active[ $basename ] ) ? 'active' : 'inactive' ), 'available' => null !== $seed, 'seed' => $seed, ); } return $result; } /** * Read registered theme roots directly so inventory does not populate the * persistent core theme-roots cache. * * @return list */ private function themes(): array { $directories = $this->paths->theme_roots(); $stylesheet = get_stylesheet(); $template = get_template(); $result = array(); foreach ( array_values( array_unique( array_map( 'strval', $directories ) ) ) as $directory ) { $root = realpath( $directory ); if ( false === $root || ! is_dir( $root ) ) { continue; } $root = rtrim( wp_normalize_path( $root ), '/' ); foreach ( new DirectoryIterator( $root ) as $entry ) { if ( $entry->isDot() || ! $entry->isDir() ) { continue; } $slug = $entry->getFilename(); if ( 0 !== validate_file( $slug ) ) { continue; } $path = $entry->getPathname(); $theme = new WP_Theme( $slug, $root ); $seed = $entry->isLink() ? null : $this->seed( 'vendor_theme', $path, 'directory', $root ); $result[] = array( 'id' => 'theme:' . $slug, 'type' => 'theme', 'name' => $this->plain_label( (string) $theme->get( 'Name' ), $slug ), 'version' => $this->plain_version( (string) $theme->get( 'Version' ) ), 'state' => $slug === $stylesheet ? 'active' : ( $slug === $template ? 'parent' : 'inactive' ), 'available' => null !== $seed, 'seed' => $seed, ); } } return $result; } /** @return array{scope:string,path:string,type:string}|null */ private function seed( string $kind, string $path, string $type, string $allowed_root ): ?array { if ( is_link( $path ) ) { return null; } $real = realpath( $path ); if ( false === $real || ( 'directory' === $type && ! is_dir( $real ) ) || ( 'file' === $type && ! is_file( $real ) ) ) { return null; } $real = wp_normalize_path( $real ); $root_real = realpath( $allowed_root ); if ( false === $root_real || 0 !== strpos( $real . '/', rtrim( wp_normalize_path( $root_real ), '/' ) . '/' ) ) { return null; } return array( 'scope' => $kind . '_' . substr( hash_hmac( 'sha256', $kind . "\0" . $real, $this->hash_key ), 0, 12 ), 'path' => $real, 'type' => $type, ); } private function plain_label( string $value, string $fallback ): string { $value = sanitize_text_field( wp_strip_all_tags( html_entity_decode( $value, ENT_QUOTES, 'UTF-8' ) ) ); return '' === $value ? $fallback : $value; } private function plain_version( string $value ): string { $value = sanitize_text_field( $value ); return substr( $value, 0, 48 ); } }