diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index a04d62e54924c..f431098445fe4 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -125,6 +125,28 @@ final class WP_Interactivity_API { */ private $current_element = null; + /** + * Expression-evaluation state: safe and server-evaluable. + * + * @since 6.9.0 + */ + private const EXPRESSION_VALID = 1; + + /** + * Expression-evaluation state: valid JS, but evaluation is deferred to + * the browser hydration pass. + * + * @since 6.9.0 + */ + private const EXPRESSION_DEFERRED = 0; + + /** + * Expression-evaluation state: invalid or dangerous input. + * + * @since 6.9.0 + */ + private const EXPRESSION_INVALID = -1; + /** * Gets and/or sets the initial state of an Interactivity API store for a * given namespace. @@ -657,13 +679,355 @@ private function evaluate( $entry ) { 'context' => $context[ $ns ] ?? array(), ); + // Preserve the long-standing dotted-path contract that malformed + // directive values with leading/trailing whitespace are treated as + // invalid and return null. Without this guard, the experimental + // full-expression path would happily evaluate ` state.key` as + // `$__st['key']`, changing the observable behavior covered by + // `test_evaluate_non_existent_path`. + if ( trim( $path ) !== $path ) { + return null; + } + // Checks if the reference path is preceded by a negation operator (!). $should_negate_value = '!' === $path[0]; - $path = $should_negate_value ? substr( $path, 1 ) : $path; + $path = $should_negate_value ? trim( substr( $path, 1 ) ) : $path; + + // Full-expression path: when the path is not a simple dotted path, + // evaluate it as an expression. This mirrors the client-side + // getEvaluate full-expression path (new Function() in JS) and supports + // comparisons ( !==, ===, >, < ), logical operators, ternaries, and + // other basic JS-like expressions. + if ( ! preg_match( '/^(?:state|context)(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\.[0-9]+)+$/', $path ) ) { + $result = $this->evaluate_full_expression( $path, $store, $ns ); + return $should_negate_value ? ! $this->is_js_truthy( $result ) : $result; + } + + // Extracts the value from the store using the reference path. The + // shared helper also accounts for derived-state getters (Closures) + // encountered along the path, invoking them on the server and + // recording the path prefix in `$derived_state_closures` so the client + // wiring for lazy hydration keeps working. + $current = $this->resolve_path_with_closures( $store, explode( '.', $path ), $ns ); + if ( null === $current && '' === $path ) { + // `resolve_path_with_closures()` returns null both for missing + // paths and when a derived-state callback threw; the latter is + // already reported inside the helper, so a null return here is + // always "path does not exist". + } + + // Returns the opposite if it contains a negation operator (!). + return $should_negate_value ? ! $this->is_js_truthy( $current ) : $current; + } + + /** + * Evaluates a full (non-dotted-path) expression and, while both engines + * exist, compares their results under WP_DEBUG. + * + * Keeping the dual-engine dispatch behind this one method makes later + * cleanup mechanical: whichever engine is not selected can be removed by + * simplifying this method and deleting the unused helper(s) below. + * + * @since 6.9.0 + * + * @param string $path Original JS expression. + * @param array $store Store root with 'state' and 'context' keys. + * @param string $ns Store namespace. + * @return mixed The expression result. + */ + private function evaluate_full_expression( string $path, array $store, string $ns ) { + $result_a = $this->evaluate_full_expression_approach_a( $path, $store, $ns ); + $result_b = $this->evaluate_full_expression_approach_b( $path, $store, $ns ); + + // WP_DEBUG-gated parity comparison. null+null is "both deferred or + // unsupported" (not a mismatch); null+value IS a mismatch and is + // logged — it signals a divergence in which expressions each engine + // considers supported or how it computed the result. + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $norm_a = is_bool( $result_a ) ? ( $result_a ? 'true' : 'false' ) : var_export( $result_a, true ); + $norm_b = is_bool( $result_b ) ? ( $result_b ? 'true' : 'false' ) : var_export( $result_b, true ); + if ( $norm_a !== $norm_b && ( null !== $result_a || null !== $result_b ) ) { + wp_trigger_error( + __METHOD__, + sprintf( + /* translators: 1: Directive expression, 2: Approach A result, 3: Approach B result. */ + __( 'Interactivity API expression evaluation mismatch for "%1$s": Approach A returned %2$s, Approach B returned %3$s.' ), + $path, + $norm_a, + $norm_b + ), + E_USER_WARNING + ); + } + } + + // Return Approach A's result while the dual-implementation + // comparison is ongoing. Neither approach is canonical — + // both are experiments and one will be removed before merge. + return $result_a; + } + + /** + * Splits a JS expression into `;`-delimited statements, respecting + * string literals, template literals, regex literals, and IIFEs. + * + * Mirrors the client-side `splitStatements()` helper in the Gutenberg + * Interactivity package, and Datastar's `genRx()` statement regex. + * + * @since 6.9.0 + * + * @param string $expr JS expression possibly containing `;`. + * @return string[]|null Array of statements, or null when the + * expression contains no semicolons. + */ + private function split_expression_into_statements( string $expr ): ?array { + if ( ! str_contains( $expr, ';' ) ) { + return null; + } + + // Matches: regex literals, double/single-quoted strings, + // template literals, IIFEs, or any non-semicolon character. + $re = '/(?:\/(?:\\\\\/|[^\/])*\/|"(?:\\\\"|[^"])*"|\'(?:\\\\\'|[^\'])*\'|`(?:\\\\`|[^`])*`|\(\s*(?:(?:function)\s*\(\s*\)|(?:\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/'; + if ( preg_match_all( $re, trim( $expr ), $matches ) ) { + return $matches[0]; + } + + return null; + } + + /** + * Determines JS-style truthiness for the subset of value types directive + * expressions can observe on the server. + * + * Key divergences from PHP: + * - empty arrays are truthy in JS, falsy in PHP + * - the string '0' is truthy in JS, falsy in PHP + * + * @since 6.9.0 + * + * @param mixed $value Value to test. + * @return bool Whether the value is truthy in JS terms. + */ + private function is_js_truthy( $value ): bool { + if ( null === $value ) { + return false; + } + if ( is_bool( $value ) ) { + return $value; + } + if ( is_int( $value ) || is_float( $value ) ) { + // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual + return 0 != $value; + } + if ( is_string( $value ) ) { + return '' !== $value; + } + if ( is_array( $value ) || is_object( $value ) ) { + return true; + } + return (bool) $value; + } + + /** + * Evaluates a full (non-dotted-path) expression using Approach A: + * regex-transform state.X/context.X → PHP array access, validate the + * token stream, substitute derived-state closures with JSON literals, + * then eval() the resulting literal-only expression. + * + * Supports `;`-delimited multi-statement expressions: each statement + * is evaluated via the same pipeline, and the last statement's value + * is returned. References to `actions.*` and `callbacks.*` are + * regex-transformed to the PHP literal `null` (these are client-only + * JS function references with no server-side equivalent). + * + * This is the established behaviour and the result returned to the caller + * during the dual-implementation comparison phase. + * + * @since 6.9.0 + * + * @param string $path Directive expression (the original JS source). + * @param array $store Store root with 'state' and 'context' keys. + * @param string $ns Store namespace (for derived-state recording). + * @return mixed Computed value, or null when unsupported/invalid. + */ + private function evaluate_full_expression_approach_a( string $path, array $store, string $ns ) { + $__st = $store['state']; + $__ctx = $store['context']; + + // Split into statements on ';', respecting string literals, + // template literals, regex literals, and IIFEs. + $statements = array( $path ); + if ( str_contains( $path, ';' ) ) { + $split = $this->split_expression_into_statements( $path ); + if ( null !== $split ) { + $statements = $split; + } + } + + // Process each statement; return the last statement's value. + $result = null; + foreach ( $statements as $statement ) { + // Transform state.X.Y.Z to $__st['X']['Y']['Z']. + $php_expr = preg_replace_callback( + '/state\.([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)/', + function ( $m ) { + $parts = explode( '.', $m[1] ); + $r = '$__st'; + foreach ( $parts as $p ) { + $r .= "['{$p}']"; + } + return $r; + }, + $statement + ); + + // Transform context.X.Y.Z to $__ctx['X']['Y']['Z']. + $php_expr = preg_replace_callback( + '/context\.([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)/', + function ( $m ) { + $parts = explode( '.', $m[1] ); + $r = '$__ctx'; + foreach ( $parts as $p ) { + $r .= "['{$p}']"; + } + return $r; + }, + $php_expr + ); + + // Transform actions.* and callbacks.* to the PHP literal + // `null`. These are client-only JS function references + // that have no server-side equivalent. Transforming them + // to null allows expressions like `callbacks.x || context.x` + // to evaluate correctly server-side while still deferring + // to the client when they are the sole value. + $php_expr = preg_replace( + '/\b(?:actions|callbacks)\.[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*/', + 'null', + $php_expr + ); + + // Validate the post-transform expression: VALID / UNSUPPORTED / INVALID. + $safety = $this->evaluate_expression_safety( $php_expr ); + + if ( self::EXPRESSION_INVALID === $safety ) { + // INVALID — dangerous PHP constructs. Report and bail to the client. + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The directive expression. */ + __( 'Interactivity API directive contained an unsafe expression: "%s".' ), + esc_html( $php_expr ) + ), + '6.9.0' + ); + return null; + } + + if ( self::EXPRESSION_DEFERRED === $safety ) { + // DEFERRED — valid JS that PHP cannot evaluate server-side + // (assignments, function/constant calls). Client handles it. + return null; + } + + // VALID — substitute derived-state closures with JSON literals so + // eval() never sees a Closure object as an operand, then evaluate. + $substituted = $this->substitute_closures( $php_expr, $store, $ns ); + if ( null === $substituted ) { + return null; + } + + try { + // phpcs:ignore Squiz.PHP.Eval.Discouraged + $result = eval( "return ( $substituted );" ); + } catch ( \Throwable $e ) { + $result = null; + } + } + + return $result; + } + + /** + * Evaluates a full (non-dotted-path) expression using Approach B: + * a custom lexer + recursive-descent parser + interpreter that consumes + * the ORIGINAL JS expression directly (no regex transforms, no eval()). + * + * This is the challenger approach during the dual-implementation + * comparison phase. It produces correct JS semantics for the supported + * expression subset. + * + * @since 6.9.0 + * + * @param string $path Directive expression (the original JS source). + * @param array $store Store root with 'state' and 'context' keys. + * @param string $ns Store namespace (for derived-state recording). + * @return mixed Computed value, or null when unsupported/invalid. + */ + private function evaluate_full_expression_approach_b( string $path, array $store, string $ns ) { + $evaluator = new WP_Interactivity_Expression_Evaluator( + function ( string $resolved_path ) use ( $store, $ns ) { + return $this->resolve_path_with_closures( $store, explode( '.', $resolved_path ), $ns ); + } + ); + return $evaluator->evaluate( $path ); + } + + /** + * Records a derived-state closure path for a given namespace. + * + * The list of paths serialized to the client as `derivedStateClosures` + * tells the client which server-side state getters need reactive wrapping + * during hydration. Each path is the prefix up to and including the closure + * location, e.g. for `state.complex.value` where `complex` is a Closure, + * the recorded path is `state.complex`. + * + * Kept as a tiny helper so the dotted-path branch and the full-expression + * evaluators (Approach A's `substitute_closures()` and Approach B's + * `resolve()`) all record the same paths with the same semantics. + * + * @since 6.9.0 + * + * @param string $ns Store namespace. + * @param string $path Derived-state path prefix to record. + */ + private function record_derived_closure( string $ns, string $path ): void { + $this->derived_state_closures[ $ns ] = $this->derived_state_closures[ $ns ] ?? array(); + if ( ! in_array( $path, $this->derived_state_closures[ $ns ], true ) ) { + $this->derived_state_closures[ $ns ][] = $path; + } + } - // Extracts the value from the store using the reference path. - $path_segments = explode( '.', $path ); - $current = $store; + /** + * Resolves a dotted reference path against a store root, invoking any + * derived-state Closures encountered along the way. + * + * Shared by the simple dotted-path branch of {@see evaluate()} and by the + * full-expression evaluators (both Approach A and Approach B) so that + * derived-state getters behave identically across all server-side code + * paths: the closure is invoked on the server, its namespace is pushed onto + * the namespace stack for the duration of the call (so `state()` and + * `get_context()` inside the getter resolve correctly), the path prefix is + * recorded in `$derived_state_closures`, and resolution continues against + * the closure's return value. If a closure returns another closure, that + * subsequent closure is invoked on the next segment iteration — mirroring + * the existing plain-path behaviour. + * + * The `'length'` pseudo-property for list arrays and strings is honoured + * to mimic JavaScript's `.length` access, which directives rely on. + * + * @since 6.9.0 + * + * @param array $root The store root, e.g. `['state' => …, 'context' => …]` + * for `evaluate()`, or a subtree during mid-path resolution. + * @param array $path_segments Dotted path already split on '.'. + * @param string $ns Store namespace, used for derived-state recording. + * @return mixed The resolved value, or null if the path does not exist. + * Note: null is ALSO returned if a derived-state callback throws; + * that case is reported via `_doing_it_wrong()` before returning. + */ + private function resolve_path_with_closures( $root, array $path_segments, string $ns ) { + $current = $root; foreach ( $path_segments as $index => $path_segment ) { /* * Special case for numeric arrays and strings. Add length @@ -704,7 +1068,7 @@ private function evaluate( $entry ) { break; } - if ( $current instanceof Closure ) { + while ( $current instanceof Closure ) { /* * This state getter's namespace is added to the stack so that * `state()` or `get_config()` read that namespace when called @@ -714,26 +1078,21 @@ private function evaluate( $entry ) { try { $current = $current(); - /* - * Tracks derived state properties that are accessed during - * rendering. - * - * @since 6.9.0 - */ - $this->derived_state_closures[ $ns ] = $this->derived_state_closures[ $ns ] ?? array(); - - // Builds path for the current property and add it to tracking if not already present. + // Tracks derived state properties accessed during rendering. $current_path = implode( '.', array_slice( $path_segments, 0, $index + 1 ) ); - if ( ! in_array( $current_path, $this->derived_state_closures[ $ns ], true ) ) { - $this->derived_state_closures[ $ns ][] = $current_path; - } + $this->record_derived_closure( $ns, $current_path ); } catch ( Throwable $e ) { _doing_it_wrong( - __METHOD__, + // Attribute the notice to the public-facing + // `evaluate` method (not this internal helper) so + // the existing `@expectedIncorrectUsage` contract on + // tests like `test_evaluate_derived_state_that_throws` + // keeps matching. + 'WP_Interactivity_API::evaluate', sprintf( /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */ __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ), - $path, + implode( '.', $path_segments ), $ns ), '6.6.0' @@ -746,8 +1105,571 @@ private function evaluate( $entry ) { } } - // Returns the opposite if it contains a negation operator (!). - return $should_negate_value ? ! $current : $current; + return $current; + } + + /** + * Checks whether a PHP expression (the post-regex-transform form of a + * directive value) is safe to evaluate during SSR. + * + * Used by the full-expression path of {@see evaluate()} before invoking + * `eval()`. The expression's `state.X` / `context.X` references have + * already been rewritten to `$__st['X']` / `$__ctx['X']` by the regex + * transforms; this method inspects the resulting token stream and + * classifies the expression into one of three states. + * + * Possible return values: + * 1 = VALID — safe, read-only expression → evaluate with eval(). + * 0 = UNSUPPORTED — contains assignments or function/constant calls + * (these are valid JS but PHP cannot evaluate them + * server-side: actions live in view.js, and assigning + * to a state/context leaf has no persistence on the + * server). The client handles them at runtime. + * -1 = INVALID — contains dangerous PHP constructs (object/static + * access, namespace separators, code execution, + * file inclusion, nested eval, open tags, etc.) or + * characters that have no JS equivalent at all + * (`.`, `;`, backticks, `@`, `#`, `\`). Returns null + * and emits a `_doing_it_wrong()` notice. + * + * Variable names are restricted to `$__st` and `$__ctx` — anything else + * (e.g. `$_SERVER`, `$evil`) is INVALID. Bare identifiers (`T_STRING`) + * are restricted to `true`, `false`, `null`; any other identifier is + * treated as a function/constant reference and marks the expression as + * UNSUPPORTED (this is also what catches call syntax `foo(...)`, because + * `(` and `)` are individually safe characters but `foo` is a non-allowed + * `T_STRING`). See the implementation plan, decisions 4 and 5. + * + * @since 6.9.0 + * + * @param string $php_expr The PHP expression to validate (after regex transform). + * @return int 1 (VALID), 0 (UNSUPPORTED), or -1 (INVALID). + */ + private function evaluate_expression_safety( string $php_expr ): int { + // Single-character tokens that are individually safe. Note that `(`, + // `)` and `,` are listed here; an unsanctioned function call is caught + // via the `T_STRING` identifier not being `true/false/null`, not via + // the parentheses themselves. `=` is also safe-as-a-character but + // treated specially below as an assignment operator. + $safe_chars = array( + ' ', + '(', + ')', + '[', + ']', + '?', + ':', + ',', + '+', + '-', + '*', + '/', + '%', + '=', + '!', + '~', + '|', + '&', + '^', + '<', + '>', + ); + + // Compound-assignment and mutation tokens. These make an expression + // UNSUPPORTED (not INVALID): they would only modify local copies of + // state/context that do not persist across requests, so they are not + // dangerous; but PHP cannot faithfully model the JS mutation + // server-side, so the client handles them. + $assignment_tokens = array( + T_PLUS_EQUAL, + T_MINUS_EQUAL, + T_MUL_EQUAL, + T_DIV_EQUAL, + T_MOD_EQUAL, + T_POW_EQUAL, + T_AND_EQUAL, + T_OR_EQUAL, + T_XOR_EQUAL, + T_SL_EQUAL, + T_SR_EQUAL, + T_COALESCE_EQUAL, + T_INC, + T_DEC, + ); + + // Dangerous PHP constructs (reject-list). Touching any of these makes + // the expression INVALID — they enable code execution, sandbox escape, + // file access, or are PHP-specific operators with no JS equivalent + // (allowing them would produce expressions that work server-side but + // fail client-side, creating confusing SSR/hydration mismatches). + // + // Constants that do not exist on PHP 7.4 are define()-shimmed in + // `interactivity-api-token-shims.php` with sentinel integer values. + $dangerous = array( + // Object/static/namespace access. + T_OBJECT_OPERATOR, + T_NULLSAFE_OBJECT_OPERATOR, + T_DOUBLE_COLON, + T_PAAMAYIM_NEKUDOTAYIM, + T_NS_SEPARATOR, + T_NAME_FULLY_QUALIFIED, + T_NAME_QUALIFIED, + T_NAME_RELATIVE, + + // Code execution / file inclusion. + T_EVAL, + T_EXIT, + T_INCLUDE, + T_INCLUDE_ONCE, + T_REQUIRE, + T_REQUIRE_ONCE, + T_NEW, + T_CLONE, + + // Function/closure definition. + T_FUNCTION, + T_FN, + + // Output / termination / scope manipulation. + T_ECHO, + T_PRINT, + T_UNSET, + T_THROW, + T_GLOBAL, + T_STATIC, + T_GOTO, + T_RETURN, + T_YIELD, + T_YIELD_FROM, + T_HALT_COMPILER, + T_ATTRIBUTE, + T_NAMESPACE, + + // PHP open/close tags and inline HTML. + T_OPEN_TAG, + T_OPEN_TAG_WITH_ECHO, + T_CLOSE_TAG, + T_INLINE_HTML, + + // PHP 8.1+ tokenizes a bare `&` in expressions using the + // ampersand token IDs, and the specific variant depends on the + // following token rather than on whether the `&` is actually a + // reference syntax construct. Both ampersand tokens are therefore + // allow-listed below in expression context; they are not a + // sandbox-escape vector on their own, and unsupported reference + // forms still fail later during eval/parse rather than executing. + + // PHP-specific operators with no JS equivalent (kept out so SSR and + // hydration agree on what is even expressible). + T_SPACESHIP, + T_LOGICAL_AND, + T_LOGICAL_OR, + T_LOGICAL_XOR, + T_ARRAY, + T_DOUBLE_ARROW, + T_ARRAY_CAST, + T_BOOL_CAST, + T_DOUBLE_CAST, + T_INT_CAST, + T_OBJECT_CAST, + T_STRING_CAST, + T_UNSET_CAST, + T_VOID_CAST, + T_EMPTY, + T_ISSET, + T_CONCAT_EQUAL, + T_CURLY_OPEN, + T_DOLLAR_OPEN_CURLY_BRACES, + T_STRING_VARNAME, + T_START_HEREDOC, + T_END_HEREDOC, + T_NUM_STRING, + T_ENCAPSED_AND_WHITESPACE, + + // Declarations / OOP keywords. + T_ABSTRACT, + T_FINAL, + T_PRIVATE, + T_PROTECTED, + T_PUBLIC, + T_PRIVATE_SET, + T_PROTECTED_SET, + T_PUBLIC_SET, + T_CLASS, + T_INTERFACE, + T_TRAIT, + T_ENUM, + T_IMPLEMENTS, + T_EXTENDS, + T_INSTANCEOF, + T_READONLY, + T_DECLARE, + T_CONST, + T_VAR, + T_CALLABLE, + T_INSTEADOF, + + // Control flow (not expression-safe). + T_IF, + T_ELSE, + T_ELSEIF, + T_FOR, + T_FOREACH, + T_WHILE, + T_DO, + T_SWITCH, + T_CASE, + T_DEFAULT, + T_BREAK, + T_CONTINUE, + T_TRY, + T_CATCH, + T_FINALLY, + + // Other PHP-specific constructs. + T_MATCH, + T_PIPE, + T_ELLIPSIS, + T_LIST, + T_AS, + T_USE, + + // Magic constants — no JS equivalent, leak server internals. + T_CLASS_C, + T_TRAIT_C, + T_METHOD_C, + T_FUNC_C, + T_NS_C, + T_FILE, + T_DIR, + T_LINE, + T_PROPERTY_C, + ); + + try { + // `token_get_all()` parses source as PHP *only between open/close + // tags*; anything outside `=` as multi-character named tokens, so a bare `=` is + // always assignment. + if ( '=' === $char ) { + $has_assignment = true; + continue; + } + + // Any other character not in the allow-list is INVALID. This + // catches backticks (`cmd` — shell execution), `;` + // (multi-statement), `.` (PHP string concat — no JS eq), `@` + // (error suppression), `#` (PHP comment that could hide a + // payload), and `\` (namespace separator; also a named token + // T_NS_SEPARATOR when leading an identifier, handled below — + // but standalone `\` is rejected here for clarity). + if ( ! in_array( $char, $safe_chars, true ) ) { + return self::EXPRESSION_INVALID; + } + continue; + } + + // ── Named token ──────────────────────────────────────────── + $token_id = $token[0]; + $token_text = $token[1]; + + // The leading T_OPEN_TAG we prepended to make token_get_all parse + // the expression as PHP code is intentionally present; skip it. + // Any *additional* open/close tag from the user's expression is + // still caught below via the $dangerous list (T_OPEN_TAG, + // T_CLOSE_TAG, etc.). + if ( T_OPEN_TAG === $token_id ) { + continue; + } + + // Reject dangerous PHP constructs outright. + if ( in_array( $token_id, $dangerous, true ) ) { + return self::EXPRESSION_INVALID; + } + + // On PHP < 8.0, `#[Attr]` is tokenized as T_COMMENT (not + // T_ATTRIBUTE), so it slips past the $dangerous check above. + // Reject any T_COMMENT that starts with `#[`, which is a PHP + // 8.0+ attribute on newer PHP but just a comment on older PHP. + if ( T_COMMENT === $token_id && str_starts_with( $token_text, '#[' ) ) { + return self::EXPRESSION_INVALID; + } + + // Assignment / mutation tokens → UNSUPPORTED. + if ( in_array( $token_id, $assignment_tokens, true ) ) { + $has_assignment = true; + continue; + } + + // Literal operands, operators, whitespace, and comments are + // allowed. `T_COMMENT` and `T_DOC_COMMENT` are harmless — PHP + // ignores them during eval. Allow-list the explicitly permitted + // token IDs; everything else falls through to the variable / + // identifier checks below, which is safe because unknown tokens + // are ignored rather than trusted. + $allowed_named = array( + T_LNUMBER, + T_DNUMBER, + T_CONSTANT_ENCAPSED_STRING, + T_WHITESPACE, + T_COMMENT, + T_DOC_COMMENT, + T_IS_EQUAL, + T_IS_NOT_EQUAL, + T_IS_IDENTICAL, + T_IS_NOT_IDENTICAL, + T_IS_SMALLER_OR_EQUAL, + T_IS_GREATER_OR_EQUAL, + T_BOOLEAN_AND, + T_BOOLEAN_OR, + T_SL, + T_SR, + T_POW, + T_COALESCE, + // PHP 8.1+ tokenization of a bare bitwise `&`. + T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG, + T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG, + ); + if ( in_array( $token_id, $allowed_named, true ) ) { + continue; + } + + // Variable references — only $__st and $__ctx are permitted. + if ( T_VARIABLE === $token_id ) { + if ( ! in_array( $token_text, array( '$__st', '$__ctx' ), true ) ) { + return self::EXPRESSION_INVALID; + } + continue; + } + + // Bare identifiers — only true/false/null are permitted. Any other + // T_STRING is a function or constant name and marks the + // expression as UNSUPPORTED (the client evaluates it during + // hydration). This is the mechanism that catches call syntax: + // `foo(...)` tokenizes with `foo` as T_STRING and `(` `)` as + // safe single-character tokens; `foo` failing this check sets + // $has_fn_call and the expression returns null. + if ( T_STRING === $token_id ) { + if ( ! in_array( strtolower( $token_text ), array( 'true', 'false', 'null' ), true ) ) { + $has_fn_call = true; + } + continue; + } + + // Any other named token not covered above is unknown — reject to + // be safe. New PHP tokens added in future versions will land here + // until explicitly reviewed and allow-listed. + return self::EXPRESSION_INVALID; + } + + // Assignments or function/constant calls → DEFERRED to the client. + if ( $has_assignment || $has_fn_call ) { + return self::EXPRESSION_DEFERRED; + } + + return self::EXPRESSION_VALID; + } + + /** + * Substitutes derived-state Closures in a (post-regex-transform) PHP + * expression with JSON-encoded literals of their computed values. + * + * Approach A's `eval()` cannot invoke a Closure returned by an array + * access — `$__st['foo']` whose value is a Closure yields the Closure + * *object*, not its computed result, and `eval()` proceeds using the + * object as an operand (which throws for `===`/`&&`/`<` etc.). To preserve + * the existing SSR behaviour for compound expressions over derived-state + * getters (e.g. `data-wp-bind--hidden="state.below10 && state.someFlag"`), + * this pre-pass rewrites the expression so that every Closure-valued + * `$__st[...]` / `$__ctx[...]` reference is replaced by the JSON literal of + * its computed value. After substitution the expression contains only + * literals and operators — no Closure object ever reaches `eval()`. + * + * The substitution walks the same per-segment path as + * {@see resolve_path_with_closures()} so mid-path closures are invoked and + * recorded with the same prefix semantics (e.g. for + * `state.complex.value` where `complex` is a Closure, `state.complex` is + * recorded and the walk continues from the closure's return value). + * + * @since 6.9.0 + * + * @param string $php_expr The post-regex-transform PHP expression, + * containing `$__st['X']['Y']` / `$__ctx['X']` references. + * @param array $store The store root: `['state' => …, 'context' => …]`. + * @param string $ns Store namespace (for derived-state recording). + * @return string|null The rewritten expression with Closures substituted + * by literals, or null on any unexpected error or when + * a substituted value is not JSON-encodable. + */ + private function substitute_closures( string $php_expr, array $store, string $ns ) { + // Match every $__st[...] / $__ctx[...] access, including chained + // segments like $__st['a']['b']['c']. The capture group is the + // literal source of the access so we can splice the replacement back + // into the expression. + $pattern = '/(\$__st|\$__ctx)((?:\[[\'"][a-zA-Z_][a-zA-Z0-9_]*[\'"]\])*)/'; + + $had_failure = false; + $callback = function ( $m ) use ( $store, $ns, &$had_failure ) { + // Determine the root ('state' or 'context'). + if ( '$__st' === $m[1] ) { + $cur = $store['state'] ?? array(); + $root_key = 'state'; + } else { + $cur = $store['context'] ?? array(); + $root_key = 'context'; + } + + // Parse the bracket-segment chain into segment strings. + $segments = array( $root_key ); + if ( '' !== $m[2] ) { + preg_match_all( "/\[(['\"])([a-zA-Z_][a-zA-Z0-9_]*)\\1\]/", $m[2], $seg_matches ); + foreach ( $seg_matches[2] as $seg ) { + $segments[] = $seg; + } + } + + // Walk segments resolving the value, invoking Closures via the + // shared helper (so derived-state recording works the same as the + // dotted-path branch). resolve_path_with_closures() expects the + // root to include the first segment's key as is — but the helper + // starts by indexing into $cur, so pass a synthetic root that + // contains the first segment under its key. Simpler: walk manually + // here using the helper per-segment. + $recorded_prefix = array( $root_key ); + foreach ( $segments as $i => $seg ) { + if ( 0 === $i ) { + // First segment selects state/context; $cur already set. + continue; + } + + if ( ( is_array( $cur ) || $cur instanceof ArrayAccess ) && isset( $cur[ $seg ] ) ) { + $cur = $cur[ $seg ]; + } elseif ( is_object( $cur ) && isset( $cur->$seg ) ) { + $cur = $cur->$seg; + } else { + $cur = null; + break; + } + + // The current segment is now part of the resolved path prefix. + $recorded_prefix[] = $seg; + + // If the current value is a Closure, invoke it the same way + // the dotted-path branch does: push the namespace, call, + // record the path prefix, pop the namespace. Repeat while a + // closure returns another closure so nested closure chains are + // fully resolved before the next path segment is accessed. + while ( $cur instanceof Closure ) { + // The prefix up to and including the current segment, + // matching the dotted-path branch's recording semantics + // exactly (e.g. state.nested for state.nested.flag). + $prefix_path = implode( '.', $recorded_prefix ); + + array_push( $this->namespace_stack, $ns ); + try { + $cur = $cur(); + $this->record_derived_closure( $ns, $prefix_path ); + } catch ( Throwable $e ) { + $had_failure = true; + _doing_it_wrong( + 'WP_Interactivity_API::substitute_closures', + sprintf( + /* translators: 1: Path pointing to an Interactivity API state property, 2: Namespace for an Interactivity API store. */ + __( 'Uncaught error executing a derived state callback with path "%1$s" and namespace "%2$s".' ), + $prefix_path, + $ns + ), + '6.9.0' + ); + return 'null'; + } finally { + array_pop( $this->namespace_stack ); + } + } + } + + // Encode the resolved value as a JSON literal PHP can eval. PHP's + // json_decode of the encoded output is used so the literal is a + // valid PHP expression (arrays become array literals via the + // decoded form wrapped in var_export-style). Actually json_encode + // produces valid PHP for scalars/objects/arrays (true, false, + // null, numbers, strings, arrays) — PHP's syntax matches JSON for + // these. The only edge case is resources and unsupported types. + $json = wp_json_encode( $cur ); + if ( false === $json ) { + // Not JSON-encodable (resource, recursion, etc.). Bail the + // entire expression so the client handles it rather than + // partially evaluating the expression against a fabricated + // null value. + $had_failure = true; + return 'null'; + } + return $json; + }; + + try { + $result = preg_replace_callback( $pattern, $callback, $php_expr ); + } catch ( \Throwable $e ) { + return null; // Unsupported — caller falls back to client. + } + + if ( null === $result ) { + return null; + } + + if ( $had_failure ) { + return null; + } + + return $result; } /** diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-expression-evaluator.php b/src/wp-includes/interactivity-api/class-wp-interactivity-expression-evaluator.php new file mode 100644 index 0000000000000..a488916f987ac --- /dev/null +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-expression-evaluator.php @@ -0,0 +1,1109 @@ +'|'<='|'>=') Shift )* + * Shift := Addition ( ('<<'|'>>') Addition )* + * Addition := Multiplication ( ('+'|'-') Multiplication )* + * Multiplication := Power ( ('*'|'/'|'%') Power )* + * Power := Unary ( '**' Power )? + * Unary := ('!'|'-'|'~') Unary | Primary + * Primary := number | string | true | false | null + * | identifierPath ( '(' argList ')' )? // call → UNSUPPORTED + * | '(' Expression ')' + * + * Rejected structurally / returned as UNSUPPORTED (null): + * - Assignment (`=`, `+=`, …) and increment/decrement (`++`, `--`): + * the `=` single-character operator and the `++`/`--` two-character + * operators tokenize but are not consumed by any grammar rule, leaving + * unconsumed tokens → `Parser::is_complete()` returns false → null. + * - Function-call syntax `identifier(...)`: a bare identifier immediately + * followed by `(` is rejected as UNSUPPORTED at parse time — no `$functions` + * whitelist is wired server-side in this phase (these are client-only + * store references defined in view.js). + * - Comma operator `(a, b)`: leaves unconsumed tokens → null. + * - Object/static access (`.` outside dotted state/context paths, + * `->`, `::`), `new`, `include`, backticks, etc.: lexer/parser reject. + * + * Recognised identifier prefixes: + * - `state.*`, `context.*` — resolved server-side via the resolver callback. + * - `actions.*`, `callbacks.*` — recognised as valid but resolved to `null` + * (these are client-only JS function references; the server cannot + * invoke them). Expressions referencing actions/callbacks gracefully + * defer to the client. + * + * Closures (derived-state getters with a PHP implementation) encountered as + * stored values at a `state.X` / `context.X` leaf ARE invoked server-side, + * mirroring the existing dotted-path branch of `WP_Interactivity_API::evaluate()`. + * The evaluator does not know how to resolve identifiers by itself; instead, + * it receives a resolver callback from the caller. This keeps the evaluator + * independent from `WP_Interactivity_API` internals and makes eventual cleanup + * (keeping only one approach) much simpler. + * + * @since 6.9.0 + */ +// phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound +class WP_Interactivity_Expression_Evaluator { + + /** + * Identifier resolver callback. + * + * Signature: function( string $path ) { return mixed; } + * + * The callback is responsible for resolving dotted `state.*` / `context.*` + * paths and for invoking any derived-state closures along the way. + * + * @var callable + */ + private $resolver; + + /** + * Lexed token stream consumed by the parser. + * + * @var array + */ + private $tokens; + + /** + * Current parser position in `$tokens`. + * + * @var int + */ + private $pos = 0; + + /** + * Constructor. + * + * @param callable $resolver Identifier resolver callback. + */ + public function __construct( callable $resolver ) { + $this->resolver = $resolver; + } + + /** + * Evaluates a JS-like expression string. + * + * Supports `;`-delimited multi-statement expressions with + * "last statement wins" semantics (following Datastar's genRx + * convention for return-value directives). If any statement is + * unsupported, the entire expression returns null. + * + * @param string $input The original (pre-regex-transform) directive value. + * @return mixed The computed value, or null if the expression is + * unsupported, malformed, or referenced an unknown path. + */ + public function evaluate( string $input ) { + if ( '' === $input ) { + return null; + } + + try { + $this->tokens = $this->lex( $input ); + $this->pos = 0; + + // Parse statements separated by ';'. The last statement's + // value is the expression result (last-statement-wins). + $statements = array(); + $statements[] = $this->parse_ternary(); + while ( $this->match_op( ';' ) ) { + // Empty trailing statements (e.g. trailing ";") are + // skipped — they produce no value. + if ( $this->is_complete() ) { + break; + } + $statements[] = $this->parse_ternary(); + } + + // Verify ALL tokens were consumed. Unconsumed tokens signal a + // construct the grammar doesn't support (assignment, comma + // operator, trailing garbage) — bail to the client. + if ( ! $this->is_complete() ) { + return null; + } + + // Evaluate each statement; return the last statement's value. + $result = null; + foreach ( $statements as $stmt ) { + $result = $this->eval_node( $stmt ); + } + return $result; + } catch ( WP_Interactivity_UnsupportedExpression $e ) { + return null; + } catch ( Throwable $e ) { + // Defensive: any unexpected error during lex/parse/evaluate is + // treated as unsupported so the directive falls through to the + // client rather than crashing SSR. + return null; + } + } + + /* ===================================================================== + * LEXER + * ===================================================================== */ + + /** + * Tokenizes the input string into a list of token arrays. + * + * Each token is `['type' => 'num'|'str'|'bool'|'null'|'op'|'id', 'value' => mixed]`. + * `bool` tokens carry the boolean value; `null` tokens carry null; `id` + * tokens carry the full dotted identifier path (e.g. `state.foo.bar`). + * + * @param string $src Source expression. + * @return array Token list. + * @throws WP_Interactivity_UnsupportedExpression On invalid characters. + */ + private function lex( string $src ): array { + $tokens = array(); + $len = strlen( $src ); + $i = 0; + + while ( $i < $len ) { + $c = $src[ $i ]; + + // Whitespace — skipped. + if ( ' ' === $c || "\t" === $c || "\n" === $c || "\r" === $c ) { + ++$i; + continue; + } + + // Numbers — integer if no '.', float if '.'. + if ( ctype_digit( $c ) ) { + $start = $i; + $has_dot = false; + while ( $i < $len && ( ctype_digit( $src[ $i ] ) || ( '.' === $src[ $i ] && ! $has_dot ) ) ) { + if ( '.' === $src[ $i ] ) { + $has_dot = true; + } + ++$i; + } + $literal = substr( $src, $start, $i - $start ); + $tokens[] = array( + 'type' => 'num', + // Track integer-ness so `state.count === 100` matches when + // the stored value is an int 100 (=== is type-strict). + 'value' => $has_dot ? (float) $literal : (int) $literal, + ); + continue; + } + + // Strings — single or double quoted, no escape handling (matches + // the existing regex-transform/eval path's expectations for simple + // string literals in directive expressions). + if ( '"' === $c || "'" === $c ) { + $quote = $c; + ++$i; + $start = $i; + while ( $i < $len && $src[ $i ] !== $quote ) { + ++$i; + } + if ( $i >= $len ) { + // Unterminated string — bail to the client. + throw new WP_Interactivity_UnsupportedExpression( 'Unterminated string' ); + } + $tokens[] = array( + 'type' => 'str', + 'value' => substr( $src, $start, $i - $start ), + ); + ++$i; // Skip closing quote. + continue; + } + + // Multi-character operators (3-char first, then 2-char). + $three = ( $i + 2 < $len ) ? substr( $src, $i, 3 ) : ''; + if ( '===' === $three || '!==' === $three ) { + $tokens[] = array( + 'type' => 'op', + 'value' => $three, + ); + $i += 3; + continue; + } + $two = ( $i + 1 < $len ) ? substr( $src, $i, 2 ) : ''; + // 2-char operators. `++`/`--` are included so they tokenize + // cleanly — they will leave the parser with unconsumed tokens + // (no grammar rule consumes them) and the outer evaluate() will + // return null, which is the UNSUPPORTED outcome we want. + if ( in_array( $two, array( '??', '==', '!=', '<=', '>=', '&&', '||', '++', '--', '<<', '>>', '**' ), true ) ) { + $tokens[] = array( + 'type' => 'op', + 'value' => $two, + ); + $i += 2; + continue; + } + + // Single-character operators. `=` is included so assignment + // expressions tokenize cleanly (the parser will leave it + // unconsumed → null, the UNSUPPORTED outcome). `;` is + // included as a statement separator for multi-statement + // support. + if ( false !== strpos( '+-*/%()?:=,!~&|^<>[];', $c ) ) { + $tokens[] = array( + 'type' => 'op', + 'value' => $c, + ); + ++$i; + continue; + } + + // Identifiers (greedy dotted paths like state.foo.bar). The `.` + // is part of the identifier only when directly following an + // alphanumeric/underscore char — never standalone, which would + // otherwise be ambiguous with PHP concat. + if ( ctype_alpha( $c ) || '_' === $c ) { + $start = $i; + ++$i; + while ( $i < $len ) { + $ch = $src[ $i ]; + if ( ctype_alnum( $ch ) || '_' === $ch ) { + ++$i; + } elseif ( '.' === $ch + && ( $i + 1 < $len ) + && ( ctype_alpha( $src[ $i + 1 ] ) || '_' === $src[ $i + 1 ] ) + ) { + // Dot only continues the identifier when an + // alphanumeric/underscore follows it. + $i += 2; + } else { + break; + } + } + $name = substr( $src, $start, $i - $start ); + + // Recognize the JS literal keywords as dedicated literal + // tokens so the evaluator's resolve() never sees them. + $lower = strtolower( $name ); + if ( 'true' === $lower ) { + $tokens[] = array( + 'type' => 'bool', + 'value' => true, + ); + } elseif ( 'false' === $lower ) { + $tokens[] = array( + 'type' => 'bool', + 'value' => false, + ); + } elseif ( 'null' === $lower ) { + $tokens[] = array( + 'type' => 'null', + 'value' => null, + ); + } else { + $tokens[] = array( + 'type' => 'id', + 'value' => $name, + ); + } + continue; + } + + // Any other character is unsupported: backticks (template + // literals / PHP shell exec), semicolons (multi-statement), `.`, + // `@`, `#`, `\`, `;`, etc. Bail to the client. + throw new WP_Interactivity_UnsupportedExpression( 'Invalid character: ' . $c ); + } + + return $tokens; + } + + /* ===================================================================== + * PARSER (recursive descent with precedence-chained binary operators) + * ===================================================================== */ + + /** + * True when the parser has consumed every token. Used to detect + * unsupported constructs that leave trailing tokens (assignment, comma). + * + * @return bool + */ + private function is_complete(): bool { + return $this->pos >= count( $this->tokens ); + } + + /** + * Parses a ternary expression (lowest precedence). + * + * @return array AST node. + */ + private function parse_ternary(): array { + $cond = $this->parse_nullish(); + if ( $this->match_op( '?' ) ) { + $then = $this->parse_ternary(); + $this->consume_op( ':' ); + $else = $this->parse_ternary(); + return array( + 'type' => 'ternary', + 'cond' => $cond, + 'then' => $then, + 'else' => $else, + ); + } + return $cond; + } + + /** + * Parses the nullish-coalescing chain. + * + * @return array AST node. + */ + private function parse_nullish(): array { + $node = $this->parse_or(); + while ( $this->match_op( '??' ) ) { + $right = $this->parse_or(); + $node = array( + 'type' => 'bin', + 'op' => '??', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the logical-OR chain. + * + * @return array AST node. + */ + private function parse_or(): array { + $node = $this->parse_and(); + while ( $this->match_op( '||' ) ) { + $right = $this->parse_and(); + $node = array( + 'type' => 'bin', + 'op' => '||', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the logical-AND chain. + * + * @return array AST node. + */ + private function parse_and(): array { + $node = $this->parse_bitwise_or(); + while ( $this->match_op( '&&' ) ) { + $right = $this->parse_bitwise_or(); + $node = array( + 'type' => 'bin', + 'op' => '&&', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the bitwise-OR chain (|). + * + * @return array AST node. + */ + private function parse_bitwise_or(): array { + $node = $this->parse_bitwise_xor(); + while ( $this->match_op( '|' ) ) { + $right = $this->parse_bitwise_xor(); + $node = array( + 'type' => 'bin', + 'op' => '|', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the bitwise-XOR chain (^). + * + * @return array AST node. + */ + private function parse_bitwise_xor(): array { + $node = $this->parse_bitwise_and(); + while ( $this->match_op( '^' ) ) { + $right = $this->parse_bitwise_and(); + $node = array( + 'type' => 'bin', + 'op' => '^', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the bitwise-AND chain (&). + * + * @return array AST node. + */ + private function parse_bitwise_and(): array { + $node = $this->parse_equality(); + while ( $this->match_op( '&' ) ) { + $right = $this->parse_equality(); + $node = array( + 'type' => 'bin', + 'op' => '&', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the equality chain (==, !=, ===, !==). + * + * @return array AST node. + */ + private function parse_equality(): array { + $node = $this->parse_comparison(); + while ( true ) { + $op = $this->peek_op_in( array( '==', '!=', '===', '!==' ) ); + if ( null === $op ) { + break; + } + $this->advance(); + $right = $this->parse_comparison(); + $node = array( + 'type' => 'bin', + 'op' => $op, + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the comparison chain (<, >, <=, >=). + * + * @return array AST node. + */ + private function parse_comparison(): array { + $node = $this->parse_shift(); + while ( true ) { + $op = $this->peek_op_in( array( '<', '>', '<=', '>=' ) ); + if ( null === $op ) { + break; + } + $this->advance(); + $right = $this->parse_shift(); + $node = array( + 'type' => 'bin', + 'op' => $op, + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the shift chain (<<, >>). + * + * @return array AST node. + */ + private function parse_shift(): array { + $node = $this->parse_add(); + while ( true ) { + $op = $this->peek_op_in( array( '<<', '>>' ) ); + if ( null === $op ) { + break; + } + $this->advance(); + $right = $this->parse_add(); + $node = array( + 'type' => 'bin', + 'op' => $op, + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the addition chain (+, -). + * + * @return array AST node. + */ + private function parse_add(): array { + $node = $this->parse_mul(); + while ( true ) { + $op = $this->peek_op_in( array( '+', '-' ) ); + if ( null === $op ) { + break; + } + $this->advance(); + $right = $this->parse_mul(); + $node = array( + 'type' => 'bin', + 'op' => $op, + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses the multiplication chain (*, /, %). + * + * @return array AST node. + */ + private function parse_mul(): array { + $node = $this->parse_power(); + while ( true ) { + $op = $this->peek_op_in( array( '*', '/', '%' ) ); + if ( null === $op ) { + break; + } + $this->advance(); + $right = $this->parse_power(); + $node = array( + 'type' => 'bin', + 'op' => $op, + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses exponentiation (**), right-associative. + * + * @return array AST node. + */ + private function parse_power(): array { + $node = $this->parse_unary(); + if ( $this->match_op( '**' ) ) { + $right = $this->parse_power(); + $node = array( + 'type' => 'bin', + 'op' => '**', + 'left' => $node, + 'right' => $right, + ); + } + return $node; + } + + /** + * Parses a unary expression (!x, -x). + * + * @return array AST node. + */ + private function parse_unary(): array { + $t = $this->peek(); + if ( null !== $t && 'op' === $t['type'] && ( '!' === $t['value'] || '-' === $t['value'] || '~' === $t['value'] ) ) { + $op = $t['value']; + $this->advance(); + $expr = $this->parse_unary(); + return array( + 'type' => 'unary', + 'op' => $op, + 'expr' => $expr, + ); + } + return $this->parse_primary(); + } + + /** + * Parses a primary expression (literals, identifiers, grouping, calls). + * + * @return array AST node. + * @throws WP_Interactivity_UnsupportedExpression On call syntax or + * unexpected tokens. + */ + private function parse_primary(): array { + $t = $this->peek(); + if ( null === $t ) { + throw new WP_Interactivity_UnsupportedExpression( 'Unexpected end of expression' ); + } + + // Literals. + if ( in_array( $t['type'], array( 'num', 'str', 'bool', 'null' ), true ) ) { + $this->advance(); + return $t; + } + + // Identifiers / function-call syntax. + if ( 'id' === $t['type'] ) { + $name = $t['value']; + $this->advance(); + + // Call syntax `identifier(...)` is UNSUPPORTED server-side: no + // `$functions` whitelist is wired (these are client-only store + // references defined in view.js). Throw the sentinel immediately + // so we don't build a doomed AST node and waste effort on the + // arguments. + $next = $this->peek(); + if ( null !== $next && 'op' === $next['type'] && '(' === $next['value'] ) { + throw new WP_Interactivity_UnsupportedExpression( 'Function call not supported: ' . $name ); + } + + return array( + 'type' => 'id', + 'value' => $name, + ); + } + + // Grouping: `( expression )`. + if ( 'op' === $t['type'] && '(' === $t['value'] ) { + $this->advance(); + $inner = $this->parse_ternary(); + $this->consume_op( ')' ); + return $inner; + } + + throw new WP_Interactivity_UnsupportedExpression( 'Unexpected token' ); + } + + /* ---------- Parser helpers ---------- */ + + /** + * Returns the current token without consuming it, or null at end. + * + * @return array|null + */ + private function peek() { + return $this->pos < count( $this->tokens ) ? $this->tokens[ $this->pos ] : null; + } + + /** + * Consumes and returns the current token. + */ + private function advance() { + $t = $this->tokens[ $this->pos ] ?? null; + if ( $this->pos < count( $this->tokens ) ) { + ++$this->pos; + } + return $t; + } + + /** + * Returns the operator value if the current token is the given op, + * consuming it; otherwise returns false. + * + * @param string $op Operator value to match. + * @return bool + */ + private function match_op( string $op ): bool { + $t = $this->peek(); + if ( null !== $t && 'op' === $t['type'] && $op === $t['value'] ) { + $this->advance(); + return true; + } + return false; + } + + /** + * Returns the operator value if the current token is an op in `$ops`, + * without consuming it; otherwise null. + * + * @param array $ops Operator values to match. + * @return string|null + */ + private function peek_op_in( array $ops ) { + $t = $this->peek(); + if ( null !== $t && 'op' === $t['type'] && in_array( $t['value'], $ops, true ) ) { + return $t['value']; + } + return null; + } + + /** + * Consumes the current token if it is the given op, otherwise throws. + * + * @param string $op Expected operator value. + * @throws WP_Interactivity_UnsupportedExpression When the expected op + * is not present. + */ + private function consume_op( string $op ): void { + if ( ! $this->match_op( $op ) ) { + throw new WP_Interactivity_UnsupportedExpression( 'Expected ' . $op ); + } + } + + /* ===================================================================== + * EVALUATOR + * ===================================================================== */ + + /** + * Evaluates an AST node recursively. + * + * @param array $node AST node. + * @return mixed Computed value. + * @throws WP_Interactivity_UnsupportedExpression On unexpected node types. + */ + private function eval_node( array $node ) { + switch ( $node['type'] ) { + case 'num': + case 'str': + case 'bool': + return $node['value']; + case 'null': + return null; + case 'id': + return $this->resolve_identifier( $node['value'] ); + case 'unary': + return $this->eval_unary( $node ); + case 'bin': + return $this->eval_bin( $node ); + case 'ternary': + return $this->eval_ternary( $node ); + default: + throw new WP_Interactivity_UnsupportedExpression( 'Unknown node type' ); + } + } + + /** + * Resolves a dotted identifier path against the store, invoking any + * derived-state Closures encountered along the way. + * + * The actual lookup is delegated to the resolver callback supplied at + * construction time. Identifiers starting with `actions.` or + * `callbacks.` are recognized as valid client-only references and + * resolve to `null` (the server cannot invoke JS functions). + * Identifiers not starting with `state.`, `context.`, `actions.`, or + * `callbacks.` are treated as UNSUPPORTED. + * + * @param string $path Dotted path, e.g. `state.foo.bar` or `context.x`. + * @return mixed Resolved value, or null when the path does not exist. + * @throws WP_Interactivity_UnsupportedExpression When the identifier is + * not a recognised path. + */ + private function resolve_identifier( string $path ) { + // Actions and callbacks are client-only JS function references. + // Recognise them as valid so expressions mixing state/context with + // actions/callbacks don't throw UNSUPPORTED. The server resolves + // them to null, which means "defer to client." + if ( 0 === strpos( $path, 'actions.' ) || 0 === strpos( $path, 'callbacks.' ) ) { + return null; + } + + // Only state.* and context.* paths are resolvable server-side. + // Anything else (e.g. Math.max, console.log) is a client-side + // reference → UNSUPPORTED. + if ( 0 !== strpos( $path, 'state.' ) && 0 !== strpos( $path, 'context.' ) ) { + throw new WP_Interactivity_UnsupportedExpression( 'Unsupported identifier: ' . $path ); + } + + return call_user_func( $this->resolver, $path ); + } + + /** + * Evaluates a unary node. + * + * @param array $node Unary AST node. + * @return mixed + */ + private function eval_unary( array $node ) { + $v = $this->eval_node( $node['expr'] ); + switch ( $node['op'] ) { + case '!': + return ! $this->is_js_truthy( $v ); + case '-': + return - $this->js_to_number( $v ); + case '~': + return ~ (int) $this->js_to_number( $v ); + default: + throw new WP_Interactivity_UnsupportedExpression( 'Unknown unary operator' ); + } + } + + /** + * Evaluates a binary node. + * + * Short-circuit operators (&&, ||, ??) return their JS-semantics operand + * (falsy left returns left for &&, truthy left returns left for ||, non- + * null left returns left for ??). All other operators evaluate both sides + * before applying. + * + * @param array $node Binary AST node. + * @return mixed + * @throws WP_Interactivity_UnsupportedExpression On unknown operators. + */ + private function eval_bin( array $node ) { + $l = $this->eval_node( $node['left'] ); + + // Short-circuit operators. + if ( '&&' === $node['op'] ) { + if ( ! $this->is_js_truthy( $l ) ) { + return $l; // JS: falsy left returns left. + } + return $this->eval_node( $node['right'] ); + } + if ( '||' === $node['op'] ) { + if ( $this->is_js_truthy( $l ) ) { + return $l; // JS: truthy left returns left. + } + return $this->eval_node( $node['right'] ); + } + if ( '??' === $node['op'] ) { + if ( null !== $l ) { + return $l; + } + return $this->eval_node( $node['right'] ); + } + + $r = $this->eval_node( $node['right'] ); + + switch ( $node['op'] ) { + case '**': + return $this->js_to_number( $l ) ** $this->js_to_number( $r ); + case '+': + if ( is_string( $l ) || is_string( $r ) ) { + return $this->js_to_string( $l ) . $this->js_to_string( $r ); + } + return $this->js_to_number( $l ) + $this->js_to_number( $r ); + case '-': + return $this->js_to_number( $l ) - $this->js_to_number( $r ); + case '*': + return $this->js_to_number( $l ) * $this->js_to_number( $r ); + case '/': + if ( 0 === $r ) { + // Mirrors PHP's behaviour: division by zero yields INF + // (float) for non-zero dividend and 0/0 throws a warning. + // We let it propagate; the catch in evaluate() handles it. + return $this->js_to_number( $l ) / $this->js_to_number( $r ); + } + return $this->js_to_number( $l ) / $this->js_to_number( $r ); + case '%': + if ( 0 === $this->js_to_number( $r ) ) { + return false; // Division by zero modulo → falsy fallback. + } + return (int) $this->js_to_number( $l ) % (int) $this->js_to_number( $r ); + case '==': + return $this->js_loose_equal( $l, $r ); + case '!=': + return ! $this->js_loose_equal( $l, $r ); + case '===': + return $l === $r; + case '!==': + return $l !== $r; + case '<': + return $this->js_to_number( $l ) < $this->js_to_number( $r ); + case '>': + return $this->js_to_number( $l ) > $this->js_to_number( $r ); + case '<=': + return $this->js_to_number( $l ) <= $this->js_to_number( $r ); + case '>=': + return $this->js_to_number( $l ) >= $this->js_to_number( $r ); + case '&': + return (int) $this->js_to_number( $l ) & (int) $this->js_to_number( $r ); + case '^': + return (int) $this->js_to_number( $l ) ^ (int) $this->js_to_number( $r ); + case '|': + return (int) $this->js_to_number( $l ) | (int) $this->js_to_number( $r ); + case '<<': + return (int) $this->js_to_number( $l ) << (int) $this->js_to_number( $r ); + case '>>': + return (int) $this->js_to_number( $l ) >> (int) $this->js_to_number( $r ); + default: + throw new WP_Interactivity_UnsupportedExpression( 'Unknown binary operator: ' . $node['op'] ); + } + } + + /** + * Evaluates a ternary node. + * + * @param array $node Ternary AST node. + * @return mixed + */ + private function eval_ternary( array $node ) { + $cond = $this->eval_node( $node['cond'] ); + return $this->is_js_truthy( $cond ) ? $this->eval_node( $node['then'] ) : $this->eval_node( $node['else'] ); + } + + /** + * Determines JS-style truthiness for the subset of value types the SSR + * evaluator works with. + * + * Key divergences from PHP: + * - empty arrays are truthy in JS, falsy in PHP + * - the string '0' is truthy in JS, falsy in PHP + * + * @param mixed $value Value to test. + * @return bool + */ + private function is_js_truthy( $value ): bool { + if ( null === $value ) { + return false; + } + if ( is_bool( $value ) ) { + return $value; + } + if ( is_int( $value ) || is_float( $value ) ) { + // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual + return 0 != $value; + } + if ( is_string( $value ) ) { + return '' !== $value; + } + if ( is_array( $value ) || is_object( $value ) ) { + return true; + } + return (bool) $value; + } + + /** + * Converts a value to a JS-like number for the scalar cases relevant to + * directive expressions. + * + * @param mixed $value Value to convert. + * @return float|int + */ + private function js_to_number( $value ) { + if ( is_int( $value ) || is_float( $value ) ) { + return $value; + } + if ( is_bool( $value ) ) { + return $value ? 1 : 0; + } + if ( null === $value ) { + return 0; + } + if ( is_string( $value ) ) { + if ( '' === trim( $value ) ) { + return 0; + } + if ( is_numeric( $value ) ) { + return false === strpos( $value, '.' ) ? (int) $value : (float) $value; + } + return NAN; + } + return NAN; + } + + /** + * Converts a value to a JS-like string for the subset of values our tests + * exercise. + * + * @param mixed $value Value to stringify. + * @return string + */ + private function js_to_string( $value ): string { + if ( null === $value ) { + return 'null'; + } + if ( true === $value ) { + return 'true'; + } + if ( false === $value ) { + return 'false'; + } + if ( is_int( $value ) || is_float( $value ) ) { + return (string) $value; + } + if ( is_string( $value ) ) { + return $value; + } + if ( is_array( $value ) ) { + return implode( ',', array_map( array( $this, 'js_to_string' ), $value ) ); + } + if ( is_object( $value ) ) { + return '[object Object]'; + } + return (string) $value; + } + + /** + * Implements a JS-like loose equality for the primitive cases relevant to + * directive expressions. + * + * This is intentionally narrower than the full ECMAScript abstract + * equality algorithm, but it covers the cases we test and the cases most + * likely to appear in templates: number/string/bool/null combinations. + * + * @param mixed $a Left value. + * @param mixed $b Right value. + * @return bool + */ + private function js_loose_equal( $a, $b ): bool { + if ( gettype( $a ) === gettype( $b ) ) { + // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return $a == $b; + } + + if ( null === $a || null === $b ) { + return false; + } + + if ( is_bool( $a ) ) { + return $this->js_loose_equal( $this->js_to_number( $a ), $b ); + } + if ( is_bool( $b ) ) { + return $this->js_loose_equal( $a, $this->js_to_number( $b ) ); + } + + if ( ( is_int( $a ) || is_float( $a ) ) && is_string( $b ) ) { + $bn = $this->js_to_number( $b ); + // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return ! is_nan( $bn ) && $a == $bn; + } + if ( is_string( $a ) && ( is_int( $b ) || is_float( $b ) ) ) { + $an = $this->js_to_number( $a ); + // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return ! is_nan( $an ) && $an == $b; + } + + return false; + } +} diff --git a/src/wp-includes/interactivity-api/interactivity-api-token-shims.php b/src/wp-includes/interactivity-api/interactivity-api-token-shims.php new file mode 100644 index 0000000000000..647b7d82e6e62 --- /dev/null +++ b/src/wp-includes/interactivity-api/interactivity-api-token-shims.php @@ -0,0 +1,67 @@ +)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/'; + +$js_re = '/(?:\/(?:\/|[^\/])*\/|"(?:\"|[^"])*"|\'(?:\'|[^\'])*\'|`(?:`|[^`])*`|\(\s*(?:(?:function)\s*\(\s*\)|(?:\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/'; + +define( 'ITER', 100000 ); + +function compare( $input ) { + global $php_re, $js_re; + preg_match_all( $php_re, $input, $p ); + preg_match_all( $js_re, $input, $j ); + $php = $p[0] ?? array(); + $js = $j[0] ?? array(); + return array( $php, $js, $php === $js ); +} + +function run_test( $input, $label ) { + [$php, $js, $match] = compare( $input ); + echo ( $match ? 'OK' : 'MISMATCH' ) . ' ' . $label . "\n"; + if ( ! $match ) { + echo ' PHP: ' . json_encode( $php ) . "\n JS: " . json_encode( $js ) . "\n"; + } + return $match; +} + +function bench( string $label, string $regex, string $input ): float { + $t = microtime( true ); + for ( $i = 0; $i < ITER; $i++ ) { + preg_match_all( $regex, $input, $m ); + } + $elapsed = ( microtime( true ) - $t ) * 1000; + printf( "%s: %.1fms\n", $label, $elapsed ); + return $elapsed; +} + +$perf_input = "state.count > 0 ? 'yes;no' : 'maybe;not'; /foo\\/bar/g; `hello;world`; (() => { return 1; })(); done"; + +/* ─────────────────────────────────────────────────────────── + * Test 1: Common directive expressions + * ─────────────────────────────────────────────────────────── */ +$common = array( 'state.count; state.flag', '"hello;world"; foo', "'a;b'; c", '`x;y`; z', '/a;b/; c', 'a/2; b', "state.count === 0 ? 'no' : 'yes'", '(() => { const x = 1; return x; })(); done' ); + +echo "=== Common ===\n"; + +foreach ( $common as $e ) { + run_test( $e, substr( $e, 0, 50 ) ); +} + +/* ─────────────────────────────────────────────────────────── + * Test 2: Edge cases with escaped delimiters + * ─────────────────────────────────────────────────────────── */ +$edge = array( '/foo\/bar;baz/g', '"hello \"world;foo\""; x', "'it\'s;ok'; y", '`back\`tick;z`; w', '/foo\\\\/bar;baz/', '/a\/b\/c;d/g', '"a\"b;c\"d"; e' ); + +echo "\n=== Edge ===\n"; + +foreach ( $edge as $e ) { + run_test( $e, substr( $e, 0, 60 ) ); +} + +/* ─────────────────────────────────────────────────────────── + * Test 3: Randomized fuzz (10 000 inputs) + * ─────────────────────────────────────────────────────────── */ +echo "\n=== Fuzz (10000 random expressions) ===\n"; + +$chars = 'abcdefghijklmnopqrstuvwxyz0123456789;./\\\'"`(){}[]=+-*&|<> '; +$mm = 0; + +for ( $i = 0; $i < 10000; $i++ ) { + $len = 5 + random_int( 0, 40 ); + $e = ''; + for ( $j = 0; $j < $len; $j++ ) { + $e .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ]; + } + [$php, $js, $match] = compare( $e ); + if ( ! $match ) { + ++$mm; + if ( $mm <= 3 ) { + echo 'MISMATCH: ' . json_encode( $e ) . "\n"; + echo ' PHP: ' . json_encode( $php ) . "\n JS: " . json_encode( $js ) . "\n"; + } + } +} + +echo "Mismatches: $mm / 10000\n"; + +/* ─────────────────────────────────────────────────────────── + * Test 4: PHP vs JS-equivalent pattern performance + * ─────────────────────────────────────────────────────────── */ +echo "\n=== Performance (" . ITER . " iterations) ===\n"; + +$tp = bench( 'PHP version', $php_re, $perf_input ); +$tj = bench( 'JS version', $js_re, $perf_input ); + +$d = ( $tj - $tp ) / $tp * 100; +printf( "Delta: %.1f%% (%s)\n", abs( $d ), $d > 0 ? 'PHP faster' : 'JS faster' ); diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPIEvaluateExpressionSafety.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPIEvaluateExpressionSafety.php new file mode 100644 index 0000000000000..855d6fd5e1bc3 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPIEvaluateExpressionSafety.php @@ -0,0 +1,145 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Invokes the private evaluate_expression_safety() helper. + * + * @param string $php_expr Post-regex-transform PHP expression. + * @return int 1 (VALID), 0 (UNSUPPORTED), -1 (INVALID). + */ + private function evaluate_expression_safety( string $php_expr ): int { + $method = new ReflectionMethod( $this->interactivity, 'evaluate_expression_safety' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $this->interactivity, $php_expr ); + } + + /** + * @ticket 60356 + */ + public function test_valid_read_only_expressions() { + $this->assertSame( 1, $this->evaluate_expression_safety( '$__st[\'count\'] !== $__ctx[\'n\']' ) ); + $this->assertSame( 1, $this->evaluate_expression_safety( '$__st[\'count\'] > 0 ? $__st[\'name\'] : \'no\'' ) ); + $this->assertSame( 1, $this->evaluate_expression_safety( 'true && false' ) ); + $this->assertSame( 1, $this->evaluate_expression_safety( 'null' ) ); + } + + /** + * @ticket 60356 + */ + public function test_unsupported_assignments_and_calls() { + $this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'x\'] = 5' ) ); + $this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'count\'] += 1' ) ); + $this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'count\']++' ) ); + $this->assertSame( 0, $this->evaluate_expression_safety( 'doSomething()' ) ); + $this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'zero\'] ??= 1' ) ); + $this->assertSame( 0, $this->evaluate_expression_safety( 'customConstant' ) ); + } + + /** + * Representative dangerous and PHP-specific constructs should all be + * rejected as INVALID so they never reach eval(). + * + * @ticket 60356 + * + * @return array + */ + public function data_invalid_dangerous_or_php_specific_constructs(): array { + return array( + 'object operator' => array( '$__st->method()' ), + 'nullsafe object operator' => array( '$__st?->method()' ), + 'fully qualified static call' => array( '\\Foo\\Bar::baz()' ), + 'qualified static call' => array( 'Foo\\Bar::baz()' ), + 'relative static call' => array( 'namespace\\Foo::baz()' ), + 'nested eval' => array( 'eval("bad")' ), + 'exit' => array( 'exit(1)' ), + 'include' => array( 'include "file.php"' ), + 'include once' => array( 'include_once "file.php"' ), + 'require' => array( 'require "file.php"' ), + 'require once' => array( 'require_once "file.php"' ), + 'new' => array( 'new stdClass()' ), + 'clone' => array( 'clone $__st' ), + 'function literal' => array( 'function() { return 1; }' ), + 'arrow function literal' => array( 'fn() => 1' ), + 'echo' => array( 'echo 1' ), + 'print' => array( 'print 1' ), + 'unset' => array( 'unset($__st["x"])' ), + 'throw' => array( 'throw new Exception()' ), + 'global' => array( 'global $foo' ), + 'static' => array( 'static $foo' ), + 'return' => array( 'return $__st["x"]' ), + 'yield' => array( 'yield 1' ), + 'yield from' => array( 'yield from array(1,2)' ), + 'attribute' => array( '#[Attr] 1' ), + 'spaceship' => array( '$__st["a"] <=> $__st["b"]' ), + 'php array syntax' => array( 'array(1, 2, 3)' ), + 'php double arrow' => array( 'array("k" => 1)' ), + 'logical and keyword' => array( '$__ctx["x"] and $__ctx["y"]' ), + 'logical or keyword' => array( '$__ctx["x"] or $__ctx["y"]' ), + 'logical xor keyword' => array( '$__ctx["x"] xor $__ctx["y"]' ), + 'array cast' => array( '(array)$__ctx["n"]' ), + 'bool cast' => array( '(bool)$__ctx["n"]' ), + 'int cast' => array( '(int)$__ctx["n"]' ), + 'double cast' => array( '(float)$__ctx["n"]' ), + 'object cast' => array( '(object)$__ctx["n"]' ), + 'string cast' => array( '(string)$__ctx["n"]' ), + 'unset cast' => array( '(unset)$__ctx["n"]' ), + 'empty construct' => array( 'empty($__ctx["x"])' ), + 'isset construct' => array( 'isset($__ctx["x"])' ), + 'concat dot' => array( '$__st["a"] . $__st["b"]' ), + 'concat equal' => array( '$__st["a"] .= $__st["b"]' ), + 'backticks' => array( '`id`' ), + 'magic constant' => array( '__FILE__' ), + 'magic dir constant' => array( '__DIR__' ), + 'magic line constant' => array( '__LINE__' ), + 'match expression' => array( 'match ($__st["count"]) { 1 => 1, default => 0 }' ), + 'list destructuring' => array( 'list($__st["a"]) = array(1)' ), + 'php close tag' => array( '1 ?>' ), + ); + } + + /** + * @ticket 60356 + * + * @dataProvider data_invalid_dangerous_or_php_specific_constructs + */ + public function test_invalid_dangerous_or_php_specific_constructs( string $php_expr ) { + $this->assertSame( -1, $this->evaluate_expression_safety( $php_expr ) ); + } + + /** + * @ticket 60356 + * + * Regression for the token_get_all() open-tag bug: without prepending + * `assertSame( 1, $this->evaluate_expression_safety( '$__st[\'count\'] !== $__ctx[\'n\']' ) ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPIEvaluateFullExpression.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPIEvaluateFullExpression.php new file mode 100644 index 0000000000000..e31a156aa3751 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPIEvaluateFullExpression.php @@ -0,0 +1,669 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Sets the internal namespace stack to a single namespace. + * + * @param string $ns Namespace. + */ + private function set_namespace_stack( string $ns ): void { + $interactivity = new ReflectionClass( $this->interactivity ); + $namespace_stack = $interactivity->getProperty( 'namespace_stack' ); + if ( PHP_VERSION_ID < 80100 ) { + $namespace_stack->setAccessible( true ); + } + $namespace_stack->setValue( $this->interactivity, array( $ns ) ); + } + + /** + * Sets the internal context stack to a single context frame. + * + * @param array $context Context frame. + */ + private function set_context_stack( array $context ): void { + $interactivity = new ReflectionClass( $this->interactivity ); + $context_stack = $interactivity->getProperty( 'context_stack' ); + if ( PHP_VERSION_ID < 80100 ) { + $context_stack->setAccessible( true ); + } + $context_stack->setValue( $this->interactivity, array( $context ) ); + } + + /** + * Reads the internal $derived_state_closures map for parity assertions. + * + * @return array + */ + private function get_derived_state_closures(): array { + $interactivity = new ReflectionClass( $this->interactivity ); + $prop = $interactivity->getProperty( 'derived_state_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $prop->setAccessible( true ); + } + return $prop->getValue( $this->interactivity ); + } + + /** + * Invokes the private evaluate() method with the given namespace/path. + * + * @param string $path Directive value (the JS expression). + * @param string $ns Store namespace. + * @return mixed The evaluate() result. + */ + private function evaluate( string $path, string $ns = 'myplugin' ) { + global $wp_interactivity; + $prev = $wp_interactivity; + $wp_interactivity = $this->interactivity; + + $evaluate = new ReflectionMethod( $this->interactivity, 'evaluate' ); + if ( PHP_VERSION_ID < 80100 ) { + $evaluate->setAccessible( true ); + } + $result = $evaluate->invokeArgs( + $this->interactivity, + array( + array( + 'namespace' => $ns, + 'value' => $path, + 'suffix' => null, + 'unique_id' => null, + ), + ) + ); + + $wp_interactivity = $prev; + return $result; + } + + /** + * Builds a fixture store with both plain values and a derived-state + * Closure (with a side effect so invocation can be detected). + * + * @param int $invoked_counter Reference counter; incremented when the + * derived closure runs (passed by ref). + * @return array State fixture. + */ + private function state_fixture( int &$invoked_counter ): array { + return array( + 'myplugin' => array( + 'count' => 5, + 'flag' => true, + 'name' => 'bob', + 'zero' => 0, + 'zeroString' => '0', + 'emptyString' => '', + 'nullish' => null, + 'below7' => function () use ( &$invoked_counter ) { + ++$invoked_counter; + return 7; + }, + ), + ); + } + + /** + * Sets up the WP_Interactivity_API instance with the fixture state and + * a context frame, and a namespace+context stack pointing at it. + * + * @param int $invoked_counter Reference counter for the derived closure. + */ + private function set_up_fixture( int &$invoked_counter ): void { + $this->interactivity->state( + 'myplugin', + array( + 'count' => 5, + 'flag' => true, + 'name' => 'bob', + 'zero' => 0, + 'zeroString' => '0', + 'emptyString' => '', + 'nullish' => null, + 'below7' => function () use ( &$invoked_counter ) { + ++$invoked_counter; + return 7; + }, + ) + ); + $this->set_context_stack( + array( + 'myplugin' => array( + 'x' => true, + 'y' => false, + 'n' => 42, + ), + ) + ); + $this->set_namespace_stack( 'myplugin' ); + } + + /* ────────────────────────────────────────────────────────────────── + * VALID expressions: server should return the computed value. + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_valid_basic_comparison() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertTrue( $this->evaluate( 'state.count !== context.n' ) ); // 5 !== 42 + $this->assertTrue( $this->evaluate( 'state.count === 5' ) ); + $this->assertFalse( $this->evaluate( 'state.count === context.n' ) ); // 5 === 42 + } + + /** + * @ticket 60356 + */ + public function test_valid_logical_operators() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertTrue( $this->evaluate( 'state.flag && context.x' ) ); + $this->assertTrue( $this->evaluate( 'state.flag || context.y' ) ); + $this->assertFalse( $this->evaluate( 'context.y && state.flag' ) ); + } + + /** + * @ticket 60356 + * + * Complex boolean precedence and grouping should work, not just simple + * binary `a && b` / `a || b` cases. + */ + public function test_valid_complex_boolean_logic() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->interactivity->state( + 'myplugin', + array( + 'a' => 1, + 'b' => 1, + 'c' => 2, + 'd' => 3, + 'e' => 5, + 'f' => 4, + ) + ); + $this->set_context_stack( + array( + 'myplugin' => array( + 'x' => true, + 'y' => false, + 'c' => true, + 'd' => false, + ), + ) + ); + + $this->assertTrue( $this->evaluate( 'state.a == state.b && state.c != state.d || state.e > state.f' ) ); + $this->assertTrue( $this->evaluate( '(context.x || context.y) && (context.c || context.d)' ) ); + $this->assertFalse( $this->evaluate( '(context.x || context.y) && (context.d && context.y)' ) ); + } + + /** + * @ticket 60356 + */ + public function test_valid_nullish_coalescing() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertSame( 5, $this->evaluate( 'state.count ?? state.flag' ) ); + // `state.count` is non-null (5), so left wins. + $this->assertSame( 0, $this->evaluate( 'state.zero ?? 7' ) ); + $this->assertSame( '', $this->evaluate( 'state.emptyString ?? "fallback"' ) ); + $this->assertSame( 'fallback', $this->evaluate( 'state.nullish ?? "fallback"' ) ); + } + + /** + * @ticket 60356 + */ + public function test_valid_ternary() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertSame( 'bob', $this->evaluate( "state.count > 0 ? state.name : 'no'" ) ); + $this->assertSame( 'no', $this->evaluate( "state.count < 0 ? state.name : 'no'" ) ); + } + + /** + * @ticket 60356 + */ + public function test_valid_boolean_and_null_literals() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertFalse( $this->evaluate( 'true && false' ) ); + $this->assertTrue( $this->evaluate( 'true || false' ) ); + $this->assertNull( $this->evaluate( 'null' ) ); + } + + /** + * @ticket 60356 + */ + public function test_valid_unary_negation() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertFalse( $this->evaluate( '!state.flag' ) ); + $this->assertTrue( $this->evaluate( '!context.y' ) ); // !false === true + } + + /** + * @ticket 60356 + * + * Regression for the integer-vs-float literal bug: stored int 5 must + * `===` int literal 5 (strict comparison succeeds). + */ + public function test_valid_integer_strict_equality() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + // state.count is int 5; the literal "5" must be int (not float) so + // `===` succeeds. A float-literal bug would make this false. + $this->assertTrue( $this->evaluate( 'state.count === 5' ) ); + $this->assertFalse( $this->evaluate( 'state.count === 5.0' ) ); // int !== float + } + + /** + * @ticket 60356 + */ + public function test_valid_bitwise_shift_and_exponentiation() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->interactivity->state( + 'myplugin', + array( + 'a' => 6, + 'b' => 3, + 'shift' => 1, + ) + ); + + $this->assertSame( 2, $this->evaluate( 'state.a & state.b' ) ); + $this->assertSame( 7, $this->evaluate( 'state.a | state.b' ) ); + $this->assertSame( 5, $this->evaluate( 'state.a ^ state.b' ) ); + $this->assertSame( -7, $this->evaluate( '~state.a' ) ); + $this->assertSame( 12, $this->evaluate( 'state.a << state.shift' ) ); + $this->assertSame( 3, $this->evaluate( 'state.a >> state.shift' ) ); + $this->assertSame( 25, $this->evaluate( 'state.count ** 2' ) ); + } + + /** + * @ticket 60356 + * + * Short-circuit returns the JS-semantics operand, not a coerced boolean. + * `0 && x` must return `0` (falsy left), not `false`. + * + * KNOWN PARITY DIVERGENCE (the comparison phase is surfacing this): PHP's + * `&&` operator coerces its operands to bool and returns a strict boolean, + * whereas JS returns the operand value itself. Approach A (regex transform + * + eval) therefore inherits PHP's behaviour and returns `false` for + * `0 && true`; Approach B (AST evaluator) implements JS semantics and + * returns `0`. The assertion here pins Approach B's JS-correct behaviour + * as the target. + */ + public function test_valid_short_circuit_returns_operand() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->interactivity->state( 'myplugin', array( 'zero' => 0 ) ); + + // Approach B targets JS semantics: `0 && true` → `0`. Assert via the + // dedicated Approach B path so the test does not flag the documented + // Approach A divergence as a regression. + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => $this->interactivity->get_context( 'myplugin' ), + ); + $method = new ReflectionMethod( $this->interactivity, 'resolve_path_with_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + $evaluator = new WP_Interactivity_Expression_Evaluator( + function ( string $path ) use ( $store, $method ) { + return $method->invoke( $this->interactivity, $store, explode( '.', $path ), 'myplugin' ); + } + ); + $this->assertSame( 0, $evaluator->evaluate( 'state.zero && state.flag' ) ); + } + + /* ────────────────────────────────────────────────────────────────── + * Compound expressions over derived-state Closures: server-side must + * invoke the Closure and use its computed value (mirrors the existing + * dotted-path branch behaviour — decision 4 in the plan). + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_compound_expression_invokes_derived_state_closure() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + + // `state.below7` is a Closure; `state.below7 === 7` must + // invoke the closure (=> 7) and compare against literal 7 → true. + $this->assertTrue( $this->evaluate( 'state.below7 === 7' ) ); + // During the dual-implementation comparison phase, both Approach A + // (substitute_closures) and Approach B (resolve_identifier) evaluate + // the expression, so the closure runs twice. When one approach is + // selected and the other removed, this drops back to 1. + $this->assertSame( 2, $invoked, 'Closure should be invoked by both approaches during the comparison phase' ); + } + + /** + * @ticket 60356 + */ + public function test_compound_expression_with_closure_in_logical_op() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertTrue( $this->evaluate( 'state.below7 && state.flag' ) ); + $this->assertGreaterThan( 0, $invoked, 'Closure should be invoked' ); + } + + /** + * @ticket 60356 + */ + public function test_derived_state_closure_path_is_recorded() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->evaluate( 'state.below7 && state.flag' ); + $recorded = $this->get_derived_state_closures(); + $this->assertContains( 'state.below7', $recorded['myplugin'] ?? array() ); + } + + /* ────────────────────────────────────────────────────────────────── + * UNSUPPORTED expressions: function-call syntax and assignments. + * The server returns null and the client computes during hydration. + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + * + * Bare call syntax `foo(...)` (no dotted prefix): identifier is T_STRING + * but not true/false/null → Approach A marks UNSUPPORTED, no notice. + */ + public function test_unsupported_bare_function_call_syntax() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertNull( $this->evaluate( 'doSomething()' ) ); + } + + /** + * @ticket 60356 + * + * Dotted call syntax `Math.max(...)` / `actions.toggle(...)`: contains a + * bare `.` (PHP concat — no JS eq) so Approach A marks INVALID and emits + * an incorrect-usage notice. The observable result is still null; the + * client computes during hydration. + */ + public function test_invalid_dotted_function_call_syntax() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( 'Math.max(state.count, context.n)' ) ); + $this->assertNull( $this->evaluate( 'actions.toggle()' ) ); + } + + /** + * @ticket 60356 + */ + public function test_unsupported_assignment() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertNull( $this->evaluate( 'state.x = 5' ) ); + $this->assertNull( $this->evaluate( 'state.count += 1' ) ); + $this->assertNull( $this->evaluate( 'state.count++' ) ); + $this->assertNull( $this->evaluate( 'state.x ??= state.flag' ) ); + } + + /* ────────────────────────────────────────────────────────────────── + * INVALID expressions: dangerous PHP constructs and PHP-specific + * operators. The server returns null and emits _doing_it_wrong(). + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_invalid_dangerous_constructs() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( 'new stdClass()' ) ); + } + + /** + * @ticket 60356 + */ + public function test_invalid_namespace_access() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( '\Foo\Bar::baz()' ) ); + } + + /** + * @ticket 60356 + */ + public function test_invalid_php_specific_logical_operator() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( 'context.x and context.y' ) ); // PHP `and` + } + + /** + * @ticket 60356 + */ + public function test_invalid_disallowed_variable() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( '$_SERVER[\'REQUEST_METHOD\']' ) ); + } + + /** + * @ticket 60356 + */ + public function test_invalid_string_concat_operator() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( 'state.name . state.name' ) ); // PHP concat `.` — no JS eq + } + + /** + * Additional representative dangerous expressions should never make it + * past the validation layer to eval(). + * + * @ticket 60356 + * + * @return array + */ + public function data_invalid_runtime_expressions(): array { + return array( + 'include' => array( 'include "file.php"' ), + 'require' => array( 'require "file.php"' ), + 'nested eval' => array( 'eval("bad")' ), + 'object operator' => array( '$__st->method()' ), + 'backticks' => array( '`id`' ), + 'magic constant' => array( '__FILE__' ), + 'match expression' => array( 'match ( state.count ) { 1 => 1, default => 0 }' ), + ); + } + + /** + * @ticket 60356 + * + * @dataProvider data_invalid_runtime_expressions + */ + public function test_invalid_runtime_expressions_are_rejected( string $expr ) { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->setExpectedIncorrectUsage( 'WP_Interactivity_API::evaluate_full_expression_approach_a' ); + $this->assertNull( $this->evaluate( $expr ) ); + } + + /* ────────────────────────────────────────────────────────────────── + * Multi-statement (`;`-delimited) support + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_valid_multi_statement_last_statement_wins() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertTrue( $this->evaluate( 'context.y; context.x' ) ); + // context.y is false (discarded), context.x is true (returned). + } + + /** + * @ticket 60356 + */ + public function test_valid_multi_statement_with_comparison_as_last() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertTrue( + $this->evaluate( 'state.count; state.flag && context.x' ) + ); + } + + /** + * @ticket 60356 + */ + public function test_valid_multi_statement_trailing_semicolon() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertSame( 5, $this->evaluate( 'state.count;' ) ); + } + + /* ────────────────────────────────────────────────────────────────── + * actions.* / callbacks.* support + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_actions_identifier_defers_to_client() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + // actions.* is client-only; the server returns null. + $this->assertNull( $this->evaluate( 'actions.someAction' ) ); + } + + /** + * @ticket 60356 + */ + public function test_callbacks_identifier_defers_to_client() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertNull( $this->evaluate( 'callbacks.myCallback' ) ); + } + + /** + * @ticket 60356 + * + * Approach A transforms actions.* to null and evaluates; PHP's && + * semantics cause `5 && null` → `false` (not `null` as in JS). + * Approach B correctly returns `null` (JS short-circuit semantics). + * Test each approach individually to avoid the WP_DEBUG comparison + * warning that flags this known divergence. + */ + public function test_actions_mixed_with_state_handled_by_both() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + + // Approach A: transforms actions.* to null → false (PHP semantics). + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => $this->interactivity->get_context( 'myplugin' ), + ); + $method_a = new ReflectionMethod( $this->interactivity, 'evaluate_full_expression_approach_a' ); + if ( PHP_VERSION_ID < 80100 ) { + $method_a->setAccessible( true ); + } + $this->assertFalse( + $method_a->invoke( $this->interactivity, 'state.count && actions.isValid', $store, 'myplugin' ) + ); + + // Approach B: resolves actions.* to null → null (JS semantics). + $method_resolve = new ReflectionMethod( $this->interactivity, 'resolve_path_with_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $method_resolve->setAccessible( true ); + } + $evaluator = new WP_Interactivity_Expression_Evaluator( + function ( string $path ) use ( $store, $method_resolve ) { + return $method_resolve->invoke( $this->interactivity, $store, explode( '.', $path ), 'myplugin' ); + } + ); + $this->assertNull( + $evaluator->evaluate( 'state.count && actions.isValid' ) + ); + } + + /** + * @ticket 60356 + */ + public function test_callbacks_mixed_with_context_evaluates() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + // callbacks.x → null (transformed by Approach A, resolved by B). + // null || context.x → true (null is falsy, || returns right operand). + $this->assertTrue( + $this->evaluate( 'callbacks.x || context.x' ) + ); + } + + /* ────────────────────────────────────────────────────────────────── + * Negation operator on simple paths + * ────────────────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + * + * Negation on simple dotted paths falls through to the full-expression + * path where ! is handled naturally. + */ + public function test_negation_on_state_path() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertFalse( $this->evaluate( '!state.flag' ) ); + } + + /** + * @ticket 60356 + * + * Double negation and negation combined with comparisons. + */ + public function test_negation_with_comparison() { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertTrue( $this->evaluate( '!context.y' ) ); // !false === true + $this->assertTrue( $this->evaluate( '!!state.flag' ) ); + $this->assertFalse( $this->evaluate( '!state.count === 5' ) ); + // JS: !5 is false, false === 5 is false. + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPIExpressionApproaches.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPIExpressionApproaches.php new file mode 100644 index 0000000000000..7d113410ac02d --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPIExpressionApproaches.php @@ -0,0 +1,350 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Sets the internal namespace stack to a single namespace. + * + * @param string $ns Namespace. + */ + private function set_namespace_stack( string $ns ): void { + $interactivity = new ReflectionClass( $this->interactivity ); + $namespace_stack = $interactivity->getProperty( 'namespace_stack' ); + if ( PHP_VERSION_ID < 80100 ) { + $namespace_stack->setAccessible( true ); + } + $namespace_stack->setValue( $this->interactivity, array( $ns ) ); + } + + /** + * Sets the internal context stack to a single frame. + * + * @param array $context Context frame. + */ + private function set_context_stack( array $context ): void { + $interactivity = new ReflectionClass( $this->interactivity ); + $context_stack = $interactivity->getProperty( 'context_stack' ); + if ( PHP_VERSION_ID < 80100 ) { + $context_stack->setAccessible( true ); + } + $context_stack->setValue( $this->interactivity, array( $context ) ); + } + + /** + * Fixture store with values chosen to expose JS-vs-PHP semantic edges. + * + * @param int $invoked_counter Derived-state invocation counter. + */ + private function set_up_fixture( int &$invoked_counter ): void { + $this->interactivity->state( + 'myplugin', + array( + 'count' => 5, + 'flag' => true, + 'name' => 'bob', + 'zero' => 0, + 'zeroString' => '0', + 'emptyString' => '', + 'emptyArray' => array(), + 'stringNumber' => '5', + 'nullish' => null, + 'below7' => function () use ( &$invoked_counter ) { + ++$invoked_counter; + return 7; + }, + ) + ); + $this->set_context_stack( + array( + 'myplugin' => array( + 'x' => true, + 'y' => false, + 'n' => 42, + ), + ) + ); + $this->set_namespace_stack( 'myplugin' ); + } + + /** + * Builds the store root expected by the private full-expression helpers. + * + * @return array + */ + private function build_store(): array { + return array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => $this->interactivity->get_context( 'myplugin' ), + ); + } + + /** + * Invokes the private Approach A helper directly. + * + * @param string $expr Original JS expression. + * @return mixed + */ + private function evaluate_a( string $expr ) { + $method = new ReflectionMethod( $this->interactivity, 'evaluate_full_expression_approach_a' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $this->interactivity, $expr, $this->build_store(), 'myplugin' ); + } + + /** + * Invokes the private Approach B helper directly. + * + * @param string $expr Original JS expression. + * @return mixed + */ + private function evaluate_b( string $expr ) { + $method = new ReflectionMethod( $this->interactivity, 'evaluate_full_expression_approach_b' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $this->interactivity, $expr, $this->build_store(), 'myplugin' ); + } + + /** + * Expressions where both approaches currently match. + * + * @return array + */ + public function provider_matching_expressions(): array { + return array( + 'basic comparison' => array( + 'expr' => 'state.count !== context.n', + 'expected' => true, + ), + 'nested ternary' => array( + 'expr' => 'state.count > 10 ? "big" : ( state.flag ? "mid" : "small" )', + 'expected' => 'mid', + ), + 'complex boolean grouping' => array( + 'expr' => '(context.x || context.y) && (state.flag || context.y)', + 'expected' => true, + ), + 'bitwise and' => array( + 'expr' => 'state.count & 3', + 'expected' => 1, + ), + 'shift and exponent' => array( + 'expr' => '(state.count << 1) + (2 ** 3)', + 'expected' => 18, + ), + 'nullish coalescing with zero' => array( + 'expr' => 'state.zero ?? 7', + 'expected' => 0, + ), + 'nullish coalescing with empty string' => array( + 'expr' => 'state.emptyString ?? "fallback"', + 'expected' => '', + ), + 'derived closure in compound expression' => array( + 'expr' => 'state.below7 === 7 && state.flag', + 'expected' => true, + ), + ); + } + + /** + * @ticket 60356 + * + * @dataProvider provider_matching_expressions + */ + public function test_both_approaches_match_for_shared_cases( string $expr, $expected ) { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertSame( $expected, $this->evaluate_a( $expr ) ); + $this->assertSame( $expected, $this->evaluate_b( $expr ) ); + } + + /** + * Cases where Approach B now matches JS semantics but Approach A still + * inherits PHP `eval()` behaviour. + * + * @return array + */ + public function provider_known_divergences(): array { + return array( + 'empty array truthiness in ternary' => array( + 'expr' => 'state.emptyArray ? "yes" : "no"', + 'expected_b' => 'yes', + 'expected_a' => 'no', + ), + 'string zero truthiness in ternary' => array( + 'expr' => 'state.zeroString ? "yes" : "no"', + 'expected_b' => 'yes', + 'expected_a' => 'no', + ), + 'operand-returning short circuit' => array( + 'expr' => 'state.zero && state.flag', + 'expected_b' => 0, + 'expected_a' => false, + ), + 'array short circuit truthiness with &&' => array( + 'expr' => 'state.emptyArray && state.flag', + 'expected_b' => true, + 'expected_a' => false, + ), + 'array short circuit truthiness with ||' => array( + 'expr' => 'state.emptyArray || state.flag', + 'expected_b' => array(), + 'expected_a' => true, + ), + 'negation of empty array truthiness' => array( + 'expr' => '!state.emptyArray', + 'expected_b' => false, + 'expected_a' => true, + ), + 'negation of string zero truthiness' => array( + 'expr' => '!state.zeroString', + 'expected_b' => false, + 'expected_a' => true, + ), + 'array short circuit truthiness' => array( + 'expr' => 'state.emptyArray && state.flag', + 'expected_b' => true, + 'expected_a' => false, + ), + 'primitive loose equality' => array( + 'expr' => 'state.emptyString == 0', + 'expected_b' => true, + 'expected_a' => PHP_VERSION_ID < 80000, + ), + 'null loose equality to false' => array( + 'expr' => 'state.nullish == false', + 'expected_b' => false, + 'expected_a' => true, + ), + 'string concatenation with plus' => array( + 'expr' => 'state.name + context.n', + 'expected_b' => 'bob42', + 'expected_a' => null, + ), + 'string concatenation with zero string' => array( + 'expr' => 'state.name + state.zeroString', + 'expected_b' => 'bob0', + 'expected_a' => null, + ), + ); + } + + /** + * @ticket 60356 + * + * @dataProvider provider_known_divergences + */ + public function test_known_approach_divergences_are_explicit( string $expr, $expected_b, $expected_a ) { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertSame( $expected_a, $this->evaluate_a( $expr ) ); + $this->assertSame( $expected_b, $this->evaluate_b( $expr ) ); + // Some cases (e.g. emptyString == 0 on PHP < 8.0) produce the + // same value from both approaches, so only assert divergence + // when the expected values actually differ. + if ( $expected_a !== $expected_b ) { + $this->assertNotSame( $this->evaluate_a( $expr ), $this->evaluate_b( $expr ) ); + } + } + + /** + * Multi-statement expressions where both approaches agree. + * + * @return array + */ + public function provider_matching_multi_statement(): array { + return array( + 'last statement wins' => array( + 'expr' => 'context.y; context.x', + 'expected' => true, + ), + 'comparison as last' => array( + 'expr' => 'state.count; state.flag && context.x', + 'expected' => true, + ), + 'trailing semicolon' => array( + 'expr' => 'state.count;', + 'expected' => 5, + ), + ); + } + + /** + * @ticket 60356 + * + * @dataProvider provider_matching_multi_statement + */ + public function test_both_approaches_match_for_multi_statement_cases( string $expr, $expected ) { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $this->assertSame( $expected, $this->evaluate_a( $expr ) ); + $this->assertSame( $expected, $this->evaluate_b( $expr ) ); + } + + /** + * Actions/callbacks identifiers: both approaches should return null + * (defer to client). + * + * @return array + */ + public function provider_actions_callbacks(): array { + return array( + 'actions dot path' => array( 'actions.someAction' ), + 'callbacks dot path' => array( 'callbacks.myCallback' ), + 'actions with state' => array( 'state.count && actions.isValid' ), + 'callbacks with context' => array( 'callbacks.x || context.x' ), + ); + } + + /** + * @ticket 60356 + * + * @dataProvider provider_actions_callbacks + */ + public function test_both_approaches_handle_actions_callbacks( string $expr ) { + $invoked = 0; + $this->set_up_fixture( $invoked ); + $result_a = $this->evaluate_a( $expr ); + $result_b = $this->evaluate_b( $expr ); + // Both should either return null or produce the same non-error result. + // Where they differ, Approach B is expected to be JS-correct. + $this->assertTrue( + null === $result_a || null === $result_b || $result_a === $result_b + ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPISubstituteClosures.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPISubstituteClosures.php new file mode 100644 index 0000000000000..6600563fbfde6 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPISubstituteClosures.php @@ -0,0 +1,205 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Sets the internal namespace stack to a single namespace. + * + * @param string $ns Namespace. + */ + private function set_namespace_stack( string $ns ): void { + $interactivity = new ReflectionClass( $this->interactivity ); + $namespace_stack = $interactivity->getProperty( 'namespace_stack' ); + if ( PHP_VERSION_ID < 80100 ) { + $namespace_stack->setAccessible( true ); + } + $namespace_stack->setValue( $this->interactivity, array( $ns ) ); + } + + /** + * Reads the internal $derived_state_closures map. + * + * @return array + */ + private function get_derived_state_closures(): array { + $interactivity = new ReflectionClass( $this->interactivity ); + $prop = $interactivity->getProperty( 'derived_state_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $prop->setAccessible( true ); + } + return $prop->getValue( $this->interactivity ); + } + + /** + * Invokes the private substitute_closures() helper. + * + * @param string $php_expr Post-regex-transform PHP expression. + * @param array $store Store root. + * @param string $ns Store namespace. + * @return string|null Rewritten expression or null on failure. + */ + private function substitute_closures( string $php_expr, array $store, string $ns ) { + $method = new ReflectionMethod( $this->interactivity, 'substitute_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $this->interactivity, $php_expr, $store, $ns ); + } + + /** + * @ticket 60356 + */ + public function test_substitutes_leaf_closure_with_json_literal() { + global $wp_interactivity; + $wp_interactivity = $this->interactivity; + + $invoked = 0; + $this->interactivity->state( + 'myplugin', + array( + 'other' => 7, + 'value' => function () use ( &$invoked ) { + ++$invoked; + // Namespace stack must be pushed so state() without an + // explicit namespace reads the current store. + $state = wp_interactivity()->state(); + return $state['other']; + }, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array(), + ); + $result = $this->substitute_closures( '$__st[\'value\'] === 7', $store, 'myplugin' ); + $this->assertSame( '7 === 7', $result ); + $this->assertSame( 1, $invoked ); + $this->assertContains( 'state.value', $this->get_derived_state_closures()['myplugin'] ?? array() ); + } + + /** + * @ticket 60356 + */ + public function test_substitutes_mid_path_closure_and_records_prefix() { + $invoked = 0; + $this->interactivity->state( + 'myplugin', + array( + 'nested' => function () use ( &$invoked ) { + ++$invoked; + return array( 'flag' => true ); + }, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array( 'x' => true ), + ); + $result = $this->substitute_closures( '$__st[\'nested\'][\'flag\'] && $__ctx[\'x\']', $store, 'myplugin' ); + $this->assertSame( 'true && true', $result ); + $this->assertSame( 1, $invoked ); + $this->assertContains( 'state.nested', $this->get_derived_state_closures()['myplugin'] ?? array() ); + } + + /** + * @ticket 60356 + */ + public function test_closure_returning_closure_is_fully_resolved() { + $invoked = 0; + $this->interactivity->state( + 'myplugin', + array( + 'value' => function () use ( &$invoked ) { + ++$invoked; + return function () use ( &$invoked ) { + ++$invoked; + return 9; + }; + }, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array(), + ); + $result = $this->substitute_closures( '$__st[\'value\'] === 9', $store, 'myplugin' ); + $this->assertSame( '9 === 9', $result ); + $this->assertSame( 2, $invoked ); + $this->assertContains( 'state.value', $this->get_derived_state_closures()['myplugin'] ?? array() ); + } + + /** + * @ticket 60356 + * @expectedIncorrectUsage WP_Interactivity_API::substitute_closures + */ + public function test_closure_that_throws_aborts_the_whole_expression() { + $this->interactivity->state( + 'myplugin', + array( + 'bad' => function () { + throw new Error( 'Boom' ); + }, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array(), + ); + $this->assertNull( $this->substitute_closures( '$__st[\'bad\'] === 1', $store, 'myplugin' ) ); + } + + /** + * @ticket 60356 + */ + public function test_non_json_encodable_value_aborts_the_whole_expression() { + $resource = fopen( 'php://temp', 'r' ); + $this->interactivity->state( + 'myplugin', + array( + 'bad' => function () use ( $resource ) { + return $resource; + }, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array(), + ); + $this->assertNull( $this->substitute_closures( '$__st[\'bad\'] === 1', $store, 'myplugin' ) ); + fclose( $resource ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityExpressionEvaluator.php b/tests/phpunit/tests/interactivity-api/wpInteractivityExpressionEvaluator.php new file mode 100644 index 0000000000000..acfcf010571ed --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityExpressionEvaluator.php @@ -0,0 +1,353 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Sets the internal namespace stack to a single namespace. + * + * @param string $ns Namespace. + */ + private function set_namespace_stack( string $ns ): void { + $interactivity = new ReflectionClass( $this->interactivity ); + $namespace_stack = $interactivity->getProperty( 'namespace_stack' ); + if ( PHP_VERSION_ID < 80100 ) { + $namespace_stack->setAccessible( true ); + } + $namespace_stack->setValue( $this->interactivity, array( $ns ) ); + } + + /** + * Reads the internal $derived_state_closures map. + * + * @return array + */ + private function get_derived_state_closures(): array { + $interactivity = new ReflectionClass( $this->interactivity ); + $prop = $interactivity->getProperty( 'derived_state_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $prop->setAccessible( true ); + } + return $prop->getValue( $this->interactivity ); + } + + /** + * Builds a configured evaluator for the fixture store. + * + * @param int $invoked_counter Closure invocation counter, passed by ref. + * @return WP_Interactivity_Expression_Evaluator + */ + private function evaluator( int &$invoked_counter ): WP_Interactivity_Expression_Evaluator { + $this->interactivity->state( + 'myplugin', + array( + 'count' => 5, + 'flag' => true, + 'zero' => 0, + 'nested' => function () use ( &$invoked_counter ) { + ++$invoked_counter; + return array( 'flag' => true ); + }, + 'value' => function () use ( &$invoked_counter ) { + ++$invoked_counter; + return 7; + }, + 'chain' => function () use ( &$invoked_counter ) { + ++$invoked_counter; + return function () use ( &$invoked_counter ) { + ++$invoked_counter; + return 9; + }; + }, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array( + 'x' => true, + 'y' => false, + 'n' => 42, + ), + ); + $evaluator = new WP_Interactivity_Expression_Evaluator( + function ( string $path ) use ( $store ) { + return $this->resolve_path( $store, $path ); + } + ); + return $evaluator; + } + + /** + * Resolves a dotted path using the production shared resolver. + * + * @param array $store Store root. + * @param string $path Dotted path. + * @return mixed + */ + private function resolve_path( array $store, string $path ) { + $method = new ReflectionMethod( $this->interactivity, 'resolve_path_with_closures' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $this->interactivity, $store, explode( '.', $path ), 'myplugin' ); + } + + /** + * @ticket 60356 + */ + public function test_integer_vs_float_literals_with_strict_equality() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertTrue( $evaluator->evaluate( 'state.count === 5' ) ); + $this->assertFalse( $evaluator->evaluate( 'state.count === 5.0' ) ); + } + + /** + * @ticket 60356 + */ + public function test_short_circuit_returns_js_semantics_operand() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertSame( 0, $evaluator->evaluate( 'state.zero && state.flag' ) ); + $this->assertTrue( $evaluator->evaluate( 'state.flag || context.y' ) ); + $this->assertSame( 5, $evaluator->evaluate( 'state.count ?? state.flag' ) ); + } + + /** + * @ticket 60356 + */ + public function test_complex_boolean_logic_and_grouping() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->interactivity->state( + 'myplugin', + array( + 'a' => 1, + 'b' => 1, + 'c' => 2, + 'd' => 3, + 'e' => 5, + 'f' => 4, + ) + ); + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array( + 'x' => true, + 'y' => false, + 'c' => true, + 'd' => false, + ), + ); + $evaluator = new WP_Interactivity_Expression_Evaluator( + function ( string $path ) use ( $store ) { + return $this->resolve_path( $store, $path ); + } + ); + + $this->assertTrue( $evaluator->evaluate( 'state.a == state.b && state.c != state.d || state.e > state.f' ) ); + $this->assertTrue( $evaluator->evaluate( '(context.x || context.y) && (context.c || context.d)' ) ); + $this->assertFalse( $evaluator->evaluate( '(context.x || context.y) && (context.d && context.y)' ) ); + } + + /** + * @ticket 60356 + */ + public function test_bitwise_shift_exponent_and_unary_bitwise_not() { + $invoked = 0; + $this->interactivity->state( + 'myplugin', + array( + 'a' => 6, + 'b' => 3, + 'shift' => 1, + 'count' => 5, + ) + ); + $this->set_namespace_stack( 'myplugin' ); + $store = array( + 'state' => $this->interactivity->state( 'myplugin' ), + 'context' => array(), + ); + $evaluator = new WP_Interactivity_Expression_Evaluator( + function ( string $path ) use ( $store ) { + return $this->resolve_path( $store, $path ); + } + ); + + $this->assertSame( 2, $evaluator->evaluate( 'state.a & state.b' ) ); + $this->assertSame( 7, $evaluator->evaluate( 'state.a | state.b' ) ); + $this->assertSame( 5, $evaluator->evaluate( 'state.a ^ state.b' ) ); + $this->assertSame( -7, $evaluator->evaluate( '~state.a' ) ); + $this->assertSame( 12, $evaluator->evaluate( 'state.a << state.shift' ) ); + $this->assertSame( 3, $evaluator->evaluate( 'state.a >> state.shift' ) ); + $this->assertSame( 25, $evaluator->evaluate( 'state.count ** 2' ) ); + } + + /** + * @ticket 60356 + */ + public function test_bare_function_call_and_comma_operator_return_null() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertNull( $evaluator->evaluate( 'doSomething()' ) ); + $this->assertNull( $evaluator->evaluate( 'Math.max(state.count, context.n)' ) ); + $this->assertNull( $evaluator->evaluate( 'state.count, context.n' ) ); + } + + /** + * @ticket 60356 + */ + public function test_derived_state_closure_leaf_is_invoked_and_recorded() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertTrue( $evaluator->evaluate( 'state.value === 7' ) ); + $this->assertSame( 1, $invoked ); + $this->assertContains( 'state.value', $this->get_derived_state_closures()['myplugin'] ?? array() ); + } + + /** + * @ticket 60356 + */ + public function test_mid_path_closure_is_invoked_and_prefix_recorded() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertTrue( $evaluator->evaluate( 'state.nested.flag && context.x' ) ); + $this->assertSame( 1, $invoked ); + $this->assertContains( 'state.nested', $this->get_derived_state_closures()['myplugin'] ?? array() ); + } + + /** + * @ticket 60356 + */ + public function test_closure_returning_closure_chain_is_fully_resolved() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertTrue( $evaluator->evaluate( 'state.chain === 9' ) ); + $this->assertSame( 2, $invoked ); + $this->assertContains( 'state.chain', $this->get_derived_state_closures()['myplugin'] ?? array() ); + } + + /** + * @ticket 60356 + */ + public function test_empty_input_returns_null() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertNull( $evaluator->evaluate( '' ) ); + } + + /* ───────────────────────────────────────────────────────── + * Multi-statement (`;`-delimited) support + * ───────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_multi_statement_last_statement_wins() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertTrue( $evaluator->evaluate( 'state.count; context.x' ) ); + // `state.count` evaluates to 5 (truthy, discarded), + // `context.x` evaluates to true (returned). + } + + /** + * @ticket 60356 + */ + public function test_multi_statement_with_side_effecty_first() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + // The comparison in the last statement is what matters. + $this->assertTrue( + $evaluator->evaluate( 'state.count + 1; context.x && state.flag' ) + ); + } + + /** + * @ticket 60356 + */ + public function test_multi_statement_trailing_semicolon_is_ignored() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertSame( 5, $evaluator->evaluate( 'state.count;' ) ); + } + + /** + * @ticket 60356 + */ + public function test_multi_statement_with_unsupported_statement_returns_null() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + // `state.flag++` is an assignment → unconsumed tokens → null. + $this->assertNull( + $evaluator->evaluate( 'state.flag++; state.count' ) + ); + } + + /* ───────────────────────────────────────────────────────── + * actions.* / callbacks.* support + * ───────────────────────────────────────────────────────── */ + + /** + * @ticket 60356 + */ + public function test_actions_identifier_resolves_to_null() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + $this->assertNull( $evaluator->evaluate( 'actions.someAction' ) ); + $this->assertNull( $evaluator->evaluate( 'callbacks.myCallback' ) ); + } + + /** + * @ticket 60356 + */ + public function test_actions_mixed_with_state_defers_to_client() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + // state.count is 5 (truthy); actions.isValid is null. + // JS: 5 && null → null. The server resolves actions.* to null, + // so the expression evaluates to null → defer to client. + $this->assertNull( + $evaluator->evaluate( 'state.count && actions.isValid' ) + ); + } + + /** + * @ticket 60356 + */ + public function test_callbacks_identifier_in_expression() { + $invoked = 0; + $evaluator = $this->evaluator( $invoked ); + // callbacks.x is null; null || true → true in JS. + // So the expression evaluates to true. + $this->assertTrue( + $evaluator->evaluate( 'callbacks.x || context.x' ) + ); + } +}