'template' ): inside the body of a `...` literal. * - array( 'type' => 'interp', 'depth' => int ): inside `${ ... }`; the * 'depth' field captures count($stack) at the moment the frame was * pushed, so closes_interpolation() can pair this '}' with the matching * '${' rather than a sibling code block. See scan_template() and * closes_interpolation(). * * @var array[] */ private $frames = array(); /** * True once an extra or mismatched closing bracket has been seen. * * @var bool */ private $unmatched = false; /** * True while inside a regex [...] character class. * * @var bool */ private $re_class = false; /** * Last significant char seen in code state. * * @var string */ private $prev = ''; /** * The significant char before $prev. * * @var string */ private $prev_prev = ''; /** * Last identifier/keyword seen in code state. * * @var string */ private $prev_word = ''; /** * Whether $prev_word was a member access (immediately after a '.'). * * @var bool */ private $prev_dot = false; /** * Whether the given minified JS looks structurally broken/truncated. * * @since 4.6.0 * * @param string $js Minified JS to inspect. * @param string|null $original_js The pre-minification input, when available. Used * only for the gross-truncation backstop on inputs * too large to scan in full (see MAX_SCAN_BYTES). * * @return bool True if it looks broken; false if it looks intact. */ public static function looks_broken( $js, $original_js = null ) { $js = (string) $js; // Empty output is handled by the caller. if ( '' === $js ) { return false; } // Output larger than the scan budget is not lexed in full. Fall back to a // cheap size-delta check against the original: a minified output far smaller // than its input is the truncation signature even without a full scan. With // no original to compare against, assume intact. $len = strlen( $js ); if ( $len > self::MAX_SCAN_BYTES ) { if ( null === $original_js ) { return false; } $original_len = strlen( (string) $original_js ); return $original_len > 0 && $len < ( $original_len * self::TRUNCATION_RATIO ); } return ( new self( $js ) )->run(); } /** * @param string $js Minified JS to inspect. */ private function __construct( $js ) { $this->js = $js; $this->len = strlen( $js ); } /** * Run the scan and return the verdict. * * @return bool */ private function run() { while ( $this->pos < $this->len ) { switch ( $this->state ) { case self::ST_CODE: $broken = $this->scan_code(); break; case self::ST_STRING: $broken = $this->scan_string(); break; case self::ST_TEMPLATE: $broken = $this->scan_template(); break; case self::ST_REGEX: $broken = $this->scan_regex(); break; case self::ST_LINE_COMMENT: $broken = $this->scan_line_comment(); break; case self::ST_BLOCK_COMMENT: $broken = $this->scan_block_comment(); break; default: return true; // Unknown state: fail safe. } if ( $broken ) { return true; } // Structural nesting this deep never occurs in legitimate output; stop // before unbounded $stack/$frames growth can exhaust memory (an // uncatchable fatal). The depth itself is a corruption signature, so the // fail-safe verdict is "broken". if ( count( $this->stack ) > self::MAX_NESTING_DEPTH || count( $this->frames ) > self::MAX_NESTING_DEPTH ) { return true; } } return $this->is_broken_at_eof(); } /** * Scan one step while in code state. * * @return bool True to short-circuit as broken. */ private function scan_code() { $c = $this->js[ $this->pos ]; // `true`/`false` shortened to !0/!1 before a member access (e.g. // true.toString() -> !0.toString()) is invalid: `0.` is a numeric literal, // so the following identifier is a syntax error. This stays bracket-balanced, // so check it explicitly. Excludes the exponent case: `0.e5` / `0.e+5` is a // valid numeric literal, so `!0.e5` is valid and must not be flagged. if ( '!' === $c && ( '0' === $this->peek( 1 ) || '1' === $this->peek( 1 ) ) && '.' === $this->peek( 2 ) && self::is_ident_start( $this->peek( 3 ) ) && ! $this->is_exponent_at( 3 ) ) { return true; } if ( '/' === $c ) { $next = $this->peek(); if ( '/' === $next ) { $this->state = self::ST_LINE_COMMENT; $this->pos += 2; return false; } if ( '*' === $next ) { $this->state = self::ST_BLOCK_COMMENT; $this->pos += 2; return false; } if ( $this->regex_allowed_here() ) { $this->state = self::ST_REGEX; $this->re_class = false; } $this->record_prev( '/' ); ++$this->pos; return false; } if ( "'" === $c || '"' === $c ) { $this->state = self::ST_STRING; $this->string_quote = $c; ++$this->pos; return false; } if ( '`' === $c ) { $this->frames[] = array( 'type' => 'template' ); $this->state = self::ST_TEMPLATE; ++$this->pos; return false; } if ( '{' === $c || '(' === $c || '[' === $c ) { $this->stack[] = $c; $this->record_prev( $c ); ++$this->pos; return false; } if ( '}' === $c || ')' === $c || ']' === $c ) { // A '}' may close a template interpolation (${ ... }) rather than a // code block: the interpolation pushed its own '{' on the stack, so this // '}' closes the interpolation when that '{' is the current stack top. if ( '}' === $c && $this->closes_interpolation() ) { array_pop( $this->frames ); array_pop( $this->stack ); // The '{' from ${. $this->state = self::ST_TEMPLATE; $this->record_prev( '`' ); ++$this->pos; return false; } $expected = ( '}' === $c ) ? '{' : ( ( ')' === $c ) ? '(' : '[' ); if ( empty( $this->stack ) || end( $this->stack ) !== $expected ) { $this->unmatched = true; } else { array_pop( $this->stack ); } $this->record_prev( $c ); ++$this->pos; return false; } if ( ctype_space( $c ) ) { ++$this->pos; return false; } if ( self::is_ident_char( $c ) ) { $start = $this->pos; while ( $this->pos < $this->len && self::is_ident_char( $this->js[ $this->pos ] ) ) { ++$this->pos; } $this->prev_dot = ( '.' === $this->prev ); $this->prev_prev = $this->prev; $this->prev = $this->js[ $this->pos - 1 ]; $this->prev_word = substr( $this->js, $start, $this->pos - $start ); return false; } // Any other significant char (operators like + - * . etc.). $this->record_prev( $c ); ++$this->pos; return false; } /** * Scan one step while inside a '...' or "..." string. * * @return bool True to short-circuit as broken (unterminated string). */ private function scan_string() { $c = $this->js[ $this->pos ]; if ( '\\' === $c ) { // Escape sequence, including a line continuation (`\` then a line // terminator). A CRLF continuation is three bytes (`\` + CR + LF), so // consume the trailing LF too -- otherwise it is left at the new position // and trips the raw-newline check below, falsely flagging a valid // continuation as broken. A lone CR or LF after `\` is the normal 2-byte // skip. if ( "\r" === $this->peek() && "\n" === $this->peek( 2 ) ) { $this->pos += 3; } else { $this->pos += 2; } return false; } // A raw LF/CR inside a string literal is a syntax error (a real newline must // be escaped or a line continuation), so it never appears in valid minified // output -- only in truncated/corrupted output where the closing quote was // lost and a later quote happened to re-open the state. Checking ASCII LF/CR // is zero-false-positive and keeps the byte-oriented lexer simple. if ( "\n" === $c || "\r" === $c ) { return true; // Unterminated string literal. } if ( $c === $this->string_quote ) { $this->return_to_code( $c ); } ++$this->pos; return false; } /** * Scan one step while inside a `...` template literal. * * @return bool */ private function scan_template() { $c = $this->js[ $this->pos ]; if ( '\\' === $c ) { $this->pos += 2; return false; } if ( '`' === $c ) { $top = end( $this->frames ); if ( $top && 'template' === $top['type'] ) { array_pop( $this->frames ); } $this->return_to_code( '`' ); ++$this->pos; return false; } if ( '$' === $c && '{' === $this->peek() ) { $this->frames[] = array( 'type' => 'interp', 'depth' => count( $this->stack ), ); $this->stack[] = '{'; // The '{' from ${. $this->return_to_code( '{' ); $this->pos += 2; return false; } ++$this->pos; return false; } /** * Scan one step while inside a /.../ regex literal. * * @return bool True to short-circuit as broken (unterminated regex). */ private function scan_regex() { $c = $this->js[ $this->pos ]; if ( '\\' === $c ) { // Unlike a string (where `\` is a valid line continuation), a line // terminator cannot be escaped inside a regex literal -- a backslash // immediately before a raw LF/CR is the truncation signature. Check the // escaped byte before skipping over the pair. $escaped = $this->peek(); if ( "\n" === $escaped || "\r" === $escaped ) { return true; // Unterminated regex literal. } $this->pos += 2; return false; } // A raw line terminator (LF or CR) is invalid anywhere in a regex literal, // including inside a [...] character class, so this check sits above the // re_class branch. if ( "\n" === $c || "\r" === $c ) { return true; // Unterminated regex literal. } if ( $this->re_class ) { if ( ']' === $c ) { $this->re_class = false; } ++$this->pos; return false; } if ( '[' === $c ) { $this->re_class = true; ++$this->pos; return false; } if ( '/' === $c ) { $this->return_to_code( '/' ); ++$this->pos; while ( $this->pos < $this->len && ctype_alpha( $this->js[ $this->pos ] ) ) { ++$this->pos; // Skip regex flags. } return false; } ++$this->pos; return false; } /** * Scan one step while inside a // line comment. * * @return bool */ private function scan_line_comment() { // CR, LF, and CRLF all end a line comment in JS; a bare CR must close it too, // otherwise code after a CR-only line ending is swallowed and broken input // can read as intact. (CRLF closes on the CR; the trailing LF is then a // no-op space in code state.) $c = $this->js[ $this->pos ]; if ( "\n" === $c || "\r" === $c ) { $this->state = self::ST_CODE; } ++$this->pos; return false; } /** * Scan one step while inside a block comment. * * @return bool */ private function scan_block_comment() { if ( '*' === $this->js[ $this->pos ] && '/' === $this->peek() ) { $this->state = self::ST_CODE; $this->pos += 2; return false; } ++$this->pos; return false; } /** * Verdict once the whole input has been consumed. * * @return bool */ private function is_broken_at_eof() { // A line comment running to EOF is valid (e.g. a trailing // `//# sourceMappingURL=...` with no final newline). Every other open state // at EOF is a genuinely unterminated construct. if ( self::ST_LINE_COMMENT === $this->state ) { $this->state = self::ST_CODE; } if ( self::ST_CODE !== $this->state ) { return true; // Unterminated string/template/regex/block-comment. } if ( ! empty( $this->stack ) ) { return true; // Unbalanced brackets. } if ( ! empty( $this->frames ) ) { return true; // Unterminated template/interpolation. } return $this->unmatched; // Saw an extra/mismatched closing bracket. } /** * Whether the current '}' closes a template interpolation rather than a block. * * @return bool */ private function closes_interpolation() { $top = end( $this->frames ); return $top && 'interp' === $top['type'] && ( $top['depth'] + 1 ) === count( $this->stack ); } /** * Whether a `/` at the current position begins a regex literal (vs division). * * JavaScript cannot decide this locally; lexers approximate it from the * previous significant token: after a value (identifier, number, `)`, `]`, * postfix ++/--) `/` is division; after an operator/keyword that expects an * operand next, `/` begins a regex. * * @return bool */ private function regex_allowed_here() { $prev = $this->prev; if ( '' === $prev ) { return true; // Start of input. } // Postfix ++ / -- yields a value, so a following `/` is division. if ( ( '+' === $prev && '+' === $this->prev_prev ) || ( '-' === $prev && '-' === $this->prev_prev ) ) { return false; } // Punctuation/operators after which a regex literal is legal. Includes the // arithmetic/comparison binary operators so real minified expressions like // `x+"|"+/\d{1,2}/.source` (regex after a binary +) are not misread. static $puncts = array( '(' => 1, ',' => 1, '=' => 1, ':' => 1, '[' => 1, '!' => 1, '&' => 1, '|' => 1, '?' => 1, '{' => 1, ';' => 1, '}' => 1, '+' => 1, '-' => 1, '*' => 1, '%' => 1, '<' => 1, '>' => 1, '~' => 1, '^' => 1, ); if ( isset( $puncts[ $prev ] ) ) { return true; } // After a word: only the keywords below allow a regex, and only when the // word is not a property access (e.g. `a.return/b` is division). if ( self::is_ident_char( $prev ) ) { static $kw = array( 'return' => 1, 'throw' => 1, 'typeof' => 1, 'in' => 1, 'of' => 1, 'new' => 1, 'do' => 1, 'else' => 1, 'void' => 1, 'delete' => 1, 'instanceof' => 1, 'case' => 1, 'yield' => 1, 'await' => 1, ); return ! $this->prev_dot && '' !== $this->prev_word && isset( $kw[ $this->prev_word ] ); } // Closing bracket ) ], a '.', etc. -> division. return false; } /** * Record the most recent significant token (without changing state). * * @param string $char The token char. */ private function record_prev( $char ) { $this->prev_prev = $this->prev; $this->prev = $char; $this->prev_word = ''; $this->prev_dot = false; } /** * Return to code state, recording $char as the most recent token. * * @param string $char The token char (the closing delimiter / interpolation brace). */ private function return_to_code( $char ) { $this->state = self::ST_CODE; $this->record_prev( $char ); } /** * Look ahead from the current position. * * @param int $offset How far ahead to peek (default the next char). * * @return string The char, or '' if out of range. */ private function peek( $offset = 1 ) { $i = $this->pos + $offset; return ( $i < $this->len ) ? $this->js[ $i ] : ''; } /** * Whether $c can appear within a JS identifier. * * @param string $c Single character. * @return bool */ private static function is_ident_char( $c ) { return '' !== $c && ( ctype_alnum( $c ) || '_' === $c || '$' === $c ); } /** * Whether $c can start a JS identifier. * * @param string $c Single character. * @return bool */ private static function is_ident_start( $c ) { return '' !== $c && ( ctype_alpha( $c ) || '_' === $c || '$' === $c ); } /** * Whether the chars at $offset begin a valid exponent part (the `e5` of the * numeric literal `0.e5`). Lets the !0/!1 member-access check tell a real, * broken member access (`!0.toString()`) apart from a valid exponent * literal (`!0.e5`), which is not a member access at all. * * @param int $offset Offset from the current position. * @return bool */ private function is_exponent_at( $offset ) { $c = $this->peek( $offset ); if ( 'e' !== $c && 'E' !== $c ) { return false; } $next = $this->peek( $offset + 1 ); if ( '+' === $next || '-' === $next ) { $next = $this->peek( $offset + 2 ); } return '' !== $next && ctype_digit( $next ); } }