clock = $clock ?? static fn(): float => microtime( true ); } /** * Refine an action, and a batch's items (AF-25.11). * * A REST request an extension's batch expander claims is refined as * itself, and each item it carries as the single request it stands * for. A broken expander or a failing item fails the whole batch, so * the pipeline fails closed on the raw descriptor. * * @param ActionDescriptor $action The core's descriptor. */ public function refine( ActionDescriptor $action ): Refinement { $refinement = $this->refine_one( $action ); if ( array() !== $refinement->failed ) { return $refinement; } $items = $this->expand( $action ); if ( is_string( $items ) ) { return new Refinement( $action, null, array( $items ) ); } return array() === $items ? $refinement : $refinement->with_items( $items ); } /** * A batch's items, each refined; empty when no expander claims the * request or the one that does declines it; or the id of the extension * that failed. * * @param ActionDescriptor $action The core's descriptor of the batch. * @return list|string */ private function expand( ActionDescriptor $action ): array|string { $expander = self::expander_for( $this->registry->batch_expanders(), $action ); if ( null === $expander || is_string( $expander ) ) { return $expander ?? array(); } try { $items = ( $expander['expand'] )( $expander['action'] ); } catch ( \Throwable $failure ) { return $expander['id']; } if ( null === $items ) { return array(); } if ( ! is_array( $items ) || ! array_is_list( $items ) || array() === $items || count( $items ) > self::MAX_BATCH_ITEMS ) { return $expander['id']; } $refined = array(); foreach ( $items as $item ) { $descriptor = self::item_descriptor( $item ); // An item that is itself a batch would nest without bound. if ( null === $descriptor || null !== self::expander_for( $this->registry->batch_expanders(), $descriptor ) ) { return $expander['id']; } $refinement = $this->refine_one( $descriptor ); if ( array() !== $refinement->failed ) { return $refinement->failed[0]; } $refined[] = $refinement; } return $refined; } /** * The first expander whose pattern matches a REST action's route, or * the id of one whose pattern failed at match time. * * @param list $expanders Registered expanders. * @param ActionDescriptor $action The action. * @return array{id: string, pattern: string, expand: callable, action: ActionDescriptor}|string|null */ private static function expander_for( array $expanders, ActionDescriptor $action ): array|string|null { if ( 'rest' !== $action->source || null === $action->route ) { return null; } foreach ( $expanders as $expander ) { $matched = self::route_match( $expander['pattern'], $action ); if ( false === $matched ) { return $expander['id']; } if ( null !== $matched ) { return $expander + array( 'action' => $matched ); } } return null; } /** * Whether a route pattern matches an action's route, in any casing. * WordPress matches routes case-insensitively, so /WC/v3/Orders/5 * reaches the same handler as /wc/v3/orders/5 and must reach the same * extension. `(?i)` right after the opening delimiter applies to the * whole pattern, whatever its delimiter and modifiers. The extension is * handed the route as sent. Null for no match; false when the pattern * failed at match time. * * @param string $pattern Registered pattern. * @param ActionDescriptor $action A REST action with a route. */ private static function route_match( string $pattern, ActionDescriptor $action ): ActionDescriptor|false|null { $matched = preg_match( $pattern[0] . '(?i)' . substr( $pattern, 1 ), (string) $action->route ); if ( false === $matched ) { return false; } return 1 === $matched ? $action : null; } /** * The core's descriptor of one batch item, or null when the item is not * a REST write: `{method, route, input?}`, nothing else. * * @param mixed $item What the expander returned for the item. */ private static function item_descriptor( mixed $item ): ?ActionDescriptor { if ( ! is_array( $item ) || array() !== array_diff( array_keys( $item ), self::ITEM_KEYS ) ) { return null; } $method = $item['method'] ?? null; $route = $item['route'] ?? null; $input = $item['input'] ?? array(); if ( ! is_string( $method ) || ! in_array( strtoupper( $method ), self::ITEM_METHODS, true ) || ! is_string( $route ) || ! str_starts_with( $route, '/' ) || ! is_array( $input ) ) { return null; } return ActionDescriptor::from_rest( $method, $route, $input ); } /** * Refine one action: classify it, then compute its facts. * * Classifiers for its door are asked in registration order; the first * to return a classification decides. One that throws, or answers * outside the vocabulary, ends the search as a failure, and the raw * descriptor stands for the pipeline to fail closed on. * * @param ActionDescriptor $action The core's descriptor. */ private function refine_one( ActionDescriptor $action ): Refinement { $classified = $this->classify( $action ); if ( array() !== $classified->failed || array() === $this->registry->fact_providers() ) { return $classified; } $slow = array(); $facts = array(); foreach ( $this->registry->fact_providers() as $provider ) { if ( array() !== $provider['entity_types'] && ! in_array( $classified->action->entity_type, $provider['entity_types'], true ) ) { continue; } $started = ( $this->clock )(); try { $provided = ( $provider['provide'] )( $classified->action ); } catch ( \Throwable $failure ) { return new Refinement( $action, null, array( $provider['id'] ) ); } $elapsed = (int) round( ( ( $this->clock )() - $started ) * 1000 ); if ( ! is_array( $provided ) || ! self::valid_facts( $provided, $provider['facts'] ) ) { return new Refinement( $action, null, array( $provider['id'] ) ); } if ( $elapsed > self::SLOW_PROVIDER_MS ) { $slow[ $provider['id'] ] = $elapsed; } $facts = array_merge( $facts, $provided ); } if ( array() === $facts ) { return new Refinement( $classified->action, $classified->refined_by, array(), $classified->raw, $slow ); } if ( count( $facts ) > self::MAX_FACTS ) { return new Refinement( $action, null, array_values( array_unique( array_column( $this->registry->fact_providers(), 'id' ) ) ) ); } ksort( $facts, SORT_STRING ); return new Refinement( $classified->action->with_facts( $facts ), $classified->refined_by, array(), $classified->raw->with_facts( $facts ), $slow ); } /** * Whether a provider's facts are ones it declared, each with a value of * its declared type. * * @param array $facts What the provider returned. * @param array> $declared What it declared, by name. */ private static function valid_facts( array $facts, array $declared ): bool { foreach ( $facts as $key => $value ) { if ( ! is_string( $key ) || ! isset( $declared[ $key ] ) || ! self::valid_value( (string) $declared[ $key ]['type'], $value ) ) { return false; } } return count( $facts ) <= self::MAX_FACTS; } /** * Whether a value has its declared type and can be stored and hashed: * money in integer minor units, numbers finite, strings and lists * bounded. A non-finite number cannot be encoded at all. * * @param string $type Declared type. * @param mixed $value Candidate value. */ private static function valid_value( string $type, mixed $value ): bool { switch ( $type ) { case 'money': return is_int( $value ); case 'number': return is_int( $value ) || ( is_float( $value ) && is_finite( $value ) ); case 'string': return is_string( $value ) && strlen( $value ) <= self::MAX_STRING; case 'bool': return is_bool( $value ); } if ( ! is_array( $value ) || ! array_is_list( $value ) || count( $value ) > self::MAX_LIST ) { return false; } foreach ( $value as $item ) { if ( ! is_scalar( $item ) || ( is_float( $item ) && ! is_finite( $item ) ) || ( is_string( $item ) && strlen( $item ) > self::MAX_STRING ) ) { return false; } } return true; } /** * Apply the first classifier that answers. * * @param ActionDescriptor $action The core's descriptor. */ private function classify( ActionDescriptor $action ): Refinement { $candidates = $this->candidates( $action ); if ( is_string( $candidates ) ) { return new Refinement( $action, null, array( $candidates ) ); } foreach ( $candidates as $candidate ) { try { $answer = ( $candidate['classify'] )( $candidate['action'] ); } catch ( \Throwable $failure ) { return new Refinement( $action, null, array( $candidate['id'] ) ); } if ( null === $answer ) { continue; } if ( ! $answer instanceof Classification || ! self::valid( $answer ) || ! self::permitted( $action, $answer ) ) { return new Refinement( $action, null, array( $candidate['id'] ) ); } $refined = self::apply( $candidate['action'], $answer ); return self::same( $action, $refined ) ? new Refinement( $action ) : new Refinement( $refined, $candidate['id'], array(), $action ); } return new Refinement( $action ); } /** * The classifiers that may see this action: route classifiers for REST, * ability classifiers for anything naming an ability or tool. * * A pattern that fails at match time - the backtracking limit - is * reported as that extension's failure rather than skipped, so the * pipeline fails closed instead of losing the extension's precision * without a trace. * * @param ActionDescriptor $action The core's descriptor. * @return list|string * The candidates, each with the descriptor it is handed, or the * id of the extension whose pattern failed. */ private function candidates( ActionDescriptor $action ): array|string { $found = array(); if ( 'rest' === $action->source && null !== $action->route ) { foreach ( $this->registry->route_classifiers() as $classifier ) { $matched = self::route_match( $classifier['pattern'], $action ); if ( false === $matched ) { return $classifier['id']; } if ( null !== $matched ) { $found[] = array( 'id' => $classifier['id'], 'classify' => $classifier['classify'], 'action' => $matched, ); } } } if ( in_array( $action->source, array( 'ability', 'mcp' ), true ) && null !== $action->ability_name ) { foreach ( $this->registry->ability_classifiers() as $classifier ) { if ( str_starts_with( $action->ability_name, $classifier['prefix'] ) ) { $found[] = array( 'id' => $classifier['id'], 'classify' => $classifier['classify'], 'action' => $action, ); } } } return $found; } /** * Whether a classification stays inside the vocabulary the evaluator * and the ledger understand. * * @param Classification $answer A classifier's answer. */ private static function valid( Classification $answer ): bool { if ( null !== $answer->entity_type && 1 !== preg_match( Registry::ENTITY_PATTERN, $answer->entity_type ) ) { return false; } if ( null !== $answer->action_cat && ! in_array( $answer->action_cat, self::CATEGORIES, true ) ) { return false; } return null === $answer->entity_id || $answer->entity_id > 0; } /** * Whether a valid classification only adds precision the rest of the * firewall can carry. Stricter-of-two guards the decision, but the row * also feeds the rate limit, which counts only writes, and the drift * and snapshot guards, which key on the target - so a write may not * become a read, and a core-guarded entity may not be retargeted. * * @param ActionDescriptor $action The core's descriptor. * @param Classification $answer A valid classification. */ private static function permitted( ActionDescriptor $action, Classification $answer ): bool { if ( 'read' === $answer->action_cat && 'read' !== $action->action_cat ) { return false; } // Core entities keep their type and id: drift on approval and // snapshots for undo key on them, so a relabeled post would be // approved without its drift check, or undone as the wrong post, // and a relabeled user or plugin would be governed by an // extension's entity provider (AF-25.4). Only what the core could // not type is the extensions' to type. if ( 'unknown' === $action->entity_type || ! in_array( $action->entity_type, RuleSchema::CORE_ENTITY_TYPES, true ) ) { return true; } return ( null === $answer->entity_type || $answer->entity_type === $action->entity_type ) && ( null === $answer->entity_id || $answer->entity_id === $action->entity_id ); } /** * The descriptor with a classification's fields applied. * * @param ActionDescriptor $action The core's descriptor. * @param Classification $answer A valid classification. */ private static function apply( ActionDescriptor $action, Classification $answer ): ActionDescriptor { return new ActionDescriptor( $action->source, $answer->action_cat ?? $action->action_cat, $answer->entity_type ?? $action->entity_type, $answer->entity_id ?? $action->entity_id, $action->ability_name, $action->input, $action->entity_slug, $answer->destructive ?? $action->destructive, $action->replay, $action->route, $action->facts ); } /** * Whether a classification changed nothing the evaluator reads. * * @param ActionDescriptor $raw The core's descriptor. * @param ActionDescriptor $refined The classified one. */ private static function same( ActionDescriptor $raw, ActionDescriptor $refined ): bool { return $raw->action_cat === $refined->action_cat && $raw->entity_type === $refined->entity_type && $raw->entity_id === $refined->entity_id && $raw->destructive === $refined->destructive; } }