*/ private array $registry; public function __construct( string $path ) { if ( ! is_file( $path ) || ! is_readable( $path ) || filesize( $path ) > self::MAX_REGISTRY_BYTES ) { throw new InvalidArgumentException( 'registry_unavailable' ); } // The path is composed internally from the signed plugin release root. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $json = file_get_contents( $path ); if ( false === $json ) { throw new InvalidArgumentException( 'registry_unavailable' ); } try { $registry = json_decode( $json, true, 64, JSON_THROW_ON_ERROR ); } catch ( JsonException $exception ) { throw new InvalidArgumentException( 'registry_invalid' ); } if ( ! is_array( $registry ) || ! isset( $registry['checks'], $registry['budgets'] ) || ! is_array( $registry['checks'] ) || ! is_array( $registry['budgets'] ) ) { throw new InvalidArgumentException( 'registry_invalid' ); } $this->registry = $registry; } public function local_budget_for( string $check_id ): Budget { $check = $this->definition( $check_id ); if ( 'none' !== ( $check['network'] ?? null ) || 'none' !== ( $check['writes'] ?? null ) ) { throw new InvalidArgumentException( 'checker_capability_denied' ); } $budget_key = $check['budget'] ?? null; $budgets = $this->registry['budgets']; if ( ! is_string( $budget_key ) || ! isset( $budgets[ $budget_key ] ) || ! is_array( $budgets[ $budget_key ] ) ) { throw new InvalidArgumentException( 'checker_budget_missing' ); } $budget = $budgets[ $budget_key ]; foreach ( array( 'time_ms', 'memory_mb', 'items', 'requests' ) as $dimension ) { if ( ! isset( $budget[ $dimension ] ) || ! is_int( $budget[ $dimension ] ) || $budget[ $dimension ] < 0 ) { throw new InvalidArgumentException( 'checker_budget_invalid' ); } } return new Budget( array( 'milliseconds' => $budget['time_ms'], 'items' => $budget['items'], 'requests' => $budget['requests'], 'bytes' => $budget['memory_mb'] * 1024 * 1024, ) ); } public function external_budget_for( string $check_id ): Budget { $check = $this->definition( $check_id ); if ( 'external_explicit' !== ( $check['network'] ?? null ) || 'none' !== ( $check['writes'] ?? null ) ) { throw new InvalidArgumentException( 'checker_capability_denied' ); } $budget_key = $check['budget'] ?? null; $budgets = $this->registry['budgets']; if ( ! is_string( $budget_key ) || ! isset( $budgets[ $budget_key ] ) || ! is_array( $budgets[ $budget_key ] ) ) { throw new InvalidArgumentException( 'checker_budget_missing' ); } $budget = $budgets[ $budget_key ]; foreach ( array( 'time_ms', 'memory_mb', 'items', 'requests' ) as $dimension ) { if ( ! isset( $budget[ $dimension ] ) || ! is_int( $budget[ $dimension ] ) || $budget[ $dimension ] < 0 ) { throw new InvalidArgumentException( 'checker_budget_invalid' ); } } return new Budget( array( 'milliseconds' => $budget['time_ms'], 'items' => $budget['items'], 'requests' => $budget['requests'], 'bytes' => $budget['memory_mb'] * 1024 * 1024, ) ); } /** * @param list $implemented_ids Compiled implementation allowlist. * @return list */ public function local_implemented_for_profile( string $profile, array $implemented_ids ): array { $planned = array(); foreach ( $this->registry['checks'] as $check ) { if ( ! is_array( $check ) || ! isset( $check['id'], $check['profiles'], $check['network'], $check['writes'] ) || ! is_string( $check['id'] ) || ! is_array( $check['profiles'] ) ) { continue; } if ( in_array( $check['id'], $implemented_ids, true ) && in_array( $profile, $check['profiles'], true ) && 'none' === $check['network'] && 'none' === $check['writes'] ) { $planned[] = $check['id']; } } return $planned; } /** * Return only explicitly implemented same-site/read-only checks in registry order. * * @param list $implemented_ids Compiled analyzer allowlist. * @return list */ public function same_site_implemented_for_profile( string $profile, array $implemented_ids ): array { $planned = array(); foreach ( $this->registry['checks'] as $check ) { if ( ! is_array( $check ) || ! isset( $check['id'], $check['profiles'], $check['network'], $check['writes'] ) || ! is_string( $check['id'] ) || ! is_array( $check['profiles'] ) ) { continue; } if ( in_array( $check['id'], $implemented_ids, true ) && in_array( $profile, $check['profiles'], true ) && 'same_site' === $check['network'] && 'none' === $check['writes'] ) { $planned[] = $check['id']; } } return $planned; } /** * Return only explicitly implemented administrator-browser checks. * * @param list $implemented_ids Compiled browser analyzer allowlist. * @return list */ public function browser_implemented_for_profile( string $profile, array $implemented_ids ): array { $planned = array(); foreach ( $this->registry['checks'] as $check ) { if ( ! is_array( $check ) || ! isset( $check['id'], $check['profiles'], $check['adapter'], $check['network'], $check['writes'] ) || ! is_string( $check['id'] ) || ! is_array( $check['profiles'] ) ) { continue; } if ( in_array( $check['id'], $implemented_ids, true ) && in_array( $profile, $check['profiles'], true ) && 'browser' === $check['adapter'] && 'same_site' === $check['network'] && 'none' === $check['writes'] ) { $planned[] = $check['id']; } } return $planned; } /** * One page navigation and its browser-loaded resources feed every analyzer. * Request observations and browser time are therefore shared, not multiplied * by the number of Finding contracts. * * @param list $check_ids Planned browser analyzer IDs. */ public function shared_browser_plan_budget( array $check_ids ): Budget { if ( array() === $check_ids || count( $check_ids ) !== count( array_unique( $check_ids ) ) ) { throw new InvalidArgumentException( 'invalid_browser_plan' ); } foreach ( $check_ids as $check_id ) { $check = $this->definition( $check_id ); if ( 'browser' !== ( $check['adapter'] ?? null ) || 'same_site' !== ( $check['network'] ?? null ) || 'none' !== ( $check['writes'] ?? null ) || 'rendered' !== ( $check['budget'] ?? null ) ) { throw new InvalidArgumentException( 'checker_capability_denied' ); } } return new Budget( array( 'milliseconds' => 30000, 'items' => 100, 'requests' => 17, 'bytes' => 2 * 1024 * 1024, ) ); } /** * One 500-request crawl is shared by every analyzer; requests are not summed * per check. Local snapshot analysis receives a separate bounded allowance. * * @param list $check_ids Planned same-site analyzer IDs. */ public function shared_same_site_plan_budget( array $check_ids ): Budget { if ( array() === $check_ids || count( $check_ids ) !== count( array_unique( $check_ids ) ) ) { throw new InvalidArgumentException( 'invalid_same_site_plan' ); } foreach ( $check_ids as $check_id ) { $check = $this->definition( $check_id ); if ( 'same_site' !== ( $check['network'] ?? null ) || 'none' !== ( $check['writes'] ?? null ) || 'crawl' !== ( $check['budget'] ?? null ) ) { throw new InvalidArgumentException( 'checker_capability_denied' ); } } $analysis_count = count( $check_ids ); $crawl_budget = $this->registry['budgets']['crawl'] ?? null; if ( ! is_array( $crawl_budget ) ) { throw new InvalidArgumentException( 'checker_budget_missing' ); } foreach ( array( 'time_ms', 'memory_mb', 'items', 'requests' ) as $dimension ) { if ( ! isset( $crawl_budget[ $dimension ] ) || ! is_int( $crawl_budget[ $dimension ] ) || $crawl_budget[ $dimension ] < 0 ) { throw new InvalidArgumentException( 'checker_budget_invalid' ); } } return new Budget( array( // A real shared-host crawl can spend several minutes waiting on // successful but slow same-site responses. Keep a finite ten-minute // server-work envelope and reserve separate time for every local // analyzer so the crawl cannot leave the report without conclusions. 'milliseconds' => $crawl_budget['time_ms'] + $this->same_site_analysis_reserve( $analysis_count ), 'items' => $crawl_budget['items'] + ( 500 * $analysis_count ), 'requests' => $crawl_budget['requests'], 'bytes' => ( $crawl_budget['memory_mb'] + ( 8 * $analysis_count ) ) * 1024 * 1024, ) ); } /** * Keep analyzer time separate from the network crawl envelope. */ public function same_site_analysis_reserve( int $analysis_count ): int { if ( $analysis_count < 1 || $analysis_count > 100 ) { throw new InvalidArgumentException( 'invalid_same_site_analysis_count' ); } return self::SAME_SITE_ANALYZER_TIME_MS * $analysis_count; } /** * Sum independent local-check allowances with the one shared crawl budget. * * @param list $local_check_ids Planned local check IDs. * @param list $same_site_check_ids Planned same-site analyzer IDs. */ public function combined_plan_budget( array $local_check_ids, array $same_site_check_ids ): Budget { if ( array() !== array_intersect( $local_check_ids, $same_site_check_ids ) ) { throw new InvalidArgumentException( 'duplicate_combined_plan_check' ); } $local = $this->plan_budget( $local_check_ids ); $same_site = $this->shared_same_site_plan_budget( $same_site_check_ids ); $limits = array(); foreach ( array( 'milliseconds', 'items', 'requests', 'bytes' ) as $dimension ) { $limits[ $dimension ] = $local->remaining( $dimension ) + $same_site->remaining( $dimension ); } return new Budget( $limits ); } /** * @param list $check_ids Planned local check IDs. */ public function plan_budget( array $check_ids ): Budget { $limits = array( 'milliseconds' => 0, 'items' => 0, 'requests' => 0, 'bytes' => 0, ); foreach ( $check_ids as $check_id ) { $budget = $this->local_budget_for( $check_id ); foreach ( array_keys( $limits ) as $dimension ) { $limits[ $dimension ] += $budget->remaining( $dimension ); } } return new Budget( $limits ); } /** * @return array */ public function definition( string $check_id ): array { foreach ( $this->registry['checks'] as $check ) { if ( is_array( $check ) && isset( $check['id'] ) && $check_id === $check['id'] ) { return $check; } } throw new InvalidArgumentException( 'checker_not_registered' ); } /** * @return array */ public function policy( string $group, string $key ): array { $value = $this->registry[ $group ][ $key ] ?? null; if ( ! is_array( $value ) ) { throw new InvalidArgumentException( 'registry_policy_missing' ); } return $value; } public function registry_version(): string { $version = $this->registry['registry_version'] ?? null; return is_string( $version ) ? $version : '0.0.0'; } /** * Public documentation URLs keyed by the closed registry source ID. * * @return array */ public function source_urls(): array { $sources = $this->registry['sources'] ?? null; if ( ! is_array( $sources ) ) { return array(); } $urls = array(); foreach ( $sources as $id => $source ) { $url = is_array( $source ) ? ( $source['url'] ?? null ) : null; if ( is_string( $id ) && 1 === preg_match( '/^[a-z][a-z0-9._-]*$/', $id ) && is_string( $url ) && 1 === preg_match( '~^https://[^\s]{1,2000}$~', $url ) ) { $urls[ $id ] = $url; } } return $urls; } }