*/ private $table_source; private string $hash_key; /** * @param callable():array|null $table_source Testable read-only database adapter. */ public function __construct( FindingFactory $findings, ?callable $table_source = null, ?string $hash_key = null ) { $this->findings = $findings; $this->table_source = $table_source ?? array( $this, 'wordpress_tables' ); $this->hash_key = $hash_key ?? ( new LocalHashKey() )->derive(); } public function id(): string { return 'database.tables_size'; } /** * @return array */ public function execute( string $timestamp ): array { try { $rows = call_user_func( $this->table_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $rows ) ) { return $this->error_finding( $timestamp ); } $objects = array(); $affected = array(); $observed = 0; $invalid = 0; $truncated = count( $rows ) > self::PROCESS_LIMIT; foreach ( array_slice( $rows, 0, self::PROCESS_LIMIT ) as $row ) { $normalized = $this->normalize_row( $row ); if ( null === $normalized ) { ++$invalid; continue; } ++$observed; if ( count( $objects ) >= self::EVIDENCE_LIMIT ) { $truncated = true; continue; } $reference = hash_hmac( 'sha256', $normalized['name'], $this->hash_key ); $objects[] = array( 'reference' => $reference, 'kind' => $normalized['core'] ? 'wordpress_core' : 'custom_or_component', 'engine' => $normalized['engine'], 'row_count' => $normalized['rows'], 'data_bytes' => $normalized['data_bytes'], 'index_bytes' => $normalized['index_bytes'], 'total_bytes' => $normalized['total_bytes'], 'size_band' => $this->size_band( $normalized['total_bytes'] ), ); $affected[] = array( 'type' => 'database_table', 'reference' => $reference, ); } $total = min( count( $rows ), self::PROCESS_LIMIT ); if ( 0 === $observed ) { $status = 'not_tested'; $coverage = 'none'; } else { $status = 'info'; $coverage = 0 === $invalid && ! $truncated ? 'complete' : 'partial'; } return $this->findings->build( $this->id(), $status, array( 'objects' => $objects, 'observed_count' => $observed, 'total_count' => max( $total, $observed ), 'truncated' => $truncated, 'limitations' => array( 'Table names and the database name are replaced by site-keyed references and never stored.', 'Row counts reported by the database engine may be estimates; size bands are inventory, not automatic cleanup advice.', ), ), $observed, max( $total, $observed ), $coverage, $timestamp, $affected ); } /** * @return list */ private function wordpress_tables(): array { global $wpdb; $database = isset( $wpdb->dbname ) && is_string( $wpdb->dbname ) ? $wpdb->dbname : ''; $prefix = isset( $wpdb->prefix ) && is_string( $wpdb->prefix ) ? $wpdb->prefix : ''; if ( '' === $database || '' === $prefix ) { throw new RuntimeException( 'database_inventory_unavailable' ); } // The schema name is parameterized; the query returns bounded metadata only. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT TABLE_NAME AS table_name, ENGINE AS engine, TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s ORDER BY (COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0)) DESC, TABLE_NAME ASC LIMIT 10001', $database, $wpdb->esc_like( $prefix ) . '%' ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! is_array( $rows ) ) { throw new RuntimeException( 'database_inventory_unavailable' ); } $core_tables = array_fill_keys( $wpdb->tables( 'all', true ), true ); $global_tables = is_multisite() ? array_fill_keys( $wpdb->tables( 'global', true ), true ) : array(); $inventory = array(); $owned_prefix = $wpdb->prefix . 'px320_audit_'; foreach ( $rows as $row ) { $name = is_array( $row ) && isset( $row['table_name'] ) && is_string( $row['table_name'] ) ? $row['table_name'] : ''; if ( ! $this->belongs_to_current_site( $name, $prefix, $global_tables ) || 0 === strpos( $name, $owned_prefix ) ) { continue; } $inventory[] = array( 'name' => $name, 'core' => isset( $core_tables[ $name ] ), 'engine' => is_array( $row ) && isset( $row['engine'] ) && is_string( $row['engine'] ) ? $row['engine'] : 'unknown', 'rows' => $this->non_negative_integer( is_array( $row ) ? ( $row['table_rows'] ?? null ) : null ) ?? -1, 'data_bytes' => $this->non_negative_integer( is_array( $row ) ? ( $row['data_length'] ?? null ) : null ) ?? -1, 'index_bytes' => $this->non_negative_integer( is_array( $row ) ? ( $row['index_length'] ?? null ) : null ) ?? -1, ); } if ( count( $rows ) > self::PROCESS_LIMIT ) { $inventory[] = array( 'name' => '', 'core' => false, 'engine' => 'unknown', 'rows' => 0, 'data_bytes' => 0, 'index_bytes' => 0, ); } return $inventory; } /** * @param array $global_tables Multisite global core-table allowlist. */ private function belongs_to_current_site( string $name, string $prefix, array $global_tables ): bool { global $wpdb; if ( 0 !== strpos( $name, $prefix ) || isset( $global_tables[ $name ] ) ) { return false; } if ( is_multisite() && isset( $wpdb->base_prefix ) && is_string( $wpdb->base_prefix ) && $prefix === $wpdb->base_prefix ) { $suffix = substr( $name, strlen( $prefix ) ); return 1 !== preg_match( '/^\d+_/', $suffix ); } return true; } /** * @param mixed $row Raw adapter row. * @return array{name:string,core:bool,engine:string,rows:int,data_bytes:int,index_bytes:int,total_bytes:int}|null */ private function normalize_row( $row ): ?array { if ( ! is_array( $row ) || ! isset( $row['name'], $row['core'], $row['engine'], $row['rows'], $row['data_bytes'], $row['index_bytes'] ) || ! is_string( $row['name'] ) || ! is_bool( $row['core'] ) || ! is_string( $row['engine'] ) ) { return null; } if ( 1 !== preg_match( '/^[^\x00-\x1F\x7F]{1,' . self::MAX_TABLE_NAME . '}$/', $row['name'] ) || 1 !== preg_match( '/^[A-Za-z0-9_-]{1,' . self::MAX_ENGINE_NAME . '}$/', $row['engine'] ) ) { return null; } $rows = $this->non_negative_integer( $row['rows'] ); $data_bytes = $this->non_negative_integer( $row['data_bytes'] ); $index_bytes = $this->non_negative_integer( $row['index_bytes'] ); if ( null === $rows || null === $data_bytes || null === $index_bytes || $data_bytes > PHP_INT_MAX - $index_bytes ) { return null; } return array( 'name' => $row['name'], 'core' => $row['core'], 'engine' => strtolower( $row['engine'] ), 'rows' => $rows, 'data_bytes' => $data_bytes, 'index_bytes' => $index_bytes, 'total_bytes' => $data_bytes + $index_bytes, ); } /** * @param mixed $value Integer-like database value. */ private function non_negative_integer( $value ): ?int { if ( ! is_int( $value ) && ! is_string( $value ) ) { return null; } $value = (string) $value; if ( 1 !== preg_match( '/^\d{1,18}$/', $value ) ) { return null; } $integer = (int) $value; return $integer >= 0 ? $integer : null; } private function size_band( int $bytes ): string { if ( $bytes >= 1073741824 ) { return 'one_gib_or_more'; } if ( $bytes >= 134217728 ) { return '128_mib_to_one_gib'; } if ( $bytes >= 16777216 ) { return '16_to_128_mib'; } return 'under_16_mib'; } /** * @return array */ private function error_finding( string $timestamp ): array { return $this->findings->build( $this->id(), 'error', array( 'objects' => array(), 'observed_count' => 0, 'total_count' => 0, 'truncated' => false, 'limitations' => array( 'The bounded metadata query failed; no SQL error, schema, table, account, host or credential value was stored.' ), ), 0, 1, 'error', $timestamp ); } }