Module refinery.lib.scripts.js.deobfuscation.reflection

Inline reflectively executed JavaScript code: eval, Function constructor, constructor chains, and setTimeout/setInterval with string arguments. An obfuscator which wraps the entire program in

Function(param, code)(proxyObject)

is handled as a special case with automatic proxy object resolution.

Expand source code Browse git
"""
Inline reflectively executed JavaScript code: eval, Function constructor, constructor chains, and
setTimeout/setInterval with string arguments. An obfuscator which wraps the entire program in

    Function(param, code)(proxyObject)

is handled as a special case with automatic proxy object resolution.
"""
from __future__ import annotations

import enum

from typing import Callable, NamedTuple

from refinery.lib.scripts import Expression, Node, _clone_node, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import side_effect_free
from refinery.lib.scripts.js.analysis.model import (
    REFLECTIVE_INTRINSICS,
    SYNC_EVAL_NAMES,
    TIMER_NAMES,
    BindingKind,
    FUNCTION_NODES,
    Scope,
    SemanticModel,
    build_semantic_model,
    crosses_dynamic_scope,
    enclosing_function,
    is_member_write_target,
    is_simple_assignment_target,
    name_uses_in_scope,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    access_key,
    get_body,
    property_key,
    references_receiver_this,
    rewrite_receiver_this_to_global,
    string_value,
    walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.options import module_execution
from refinery.lib.scripts.js.deobfuscation.strict_divergence import diverges_under_strict
from refinery.lib.scripts.js.model import (
    JsAssignmentExpression,
    JsAwaitExpression,
    JsBlockStatement,
    JsCallExpression,
    JsClassDeclaration,
    JsClassExpression,
    JsExpressionStatement,
    JsFunctionExpression,
    JsIdentifier,
    JsMemberExpression,
    JsNewExpression,
    JsObjectExpression,
    JsProperty,
    JsPropertyKind,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsStringLiteral,
    JsUnaryExpression,
    Statement,
    strip_parens,
)

_REFLECTIVE_CALLEE_NAMES = REFLECTIVE_INTRINSICS | TIMER_NAMES | SYNC_EVAL_NAMES


class ReflectedScope(enum.Enum):
    """
    The execution scope of reflectively evaluated code, which decides how its free names, `this`, and
    top-level declarations must be treated when the code is inlined at its call site. A
    `Function`-constructed function and indirect `eval`/string-timer code run in the global sloppy
    scope; a direct `eval` runs in the caller's scope, which is the inline site itself, so its
    references and `this` are already correct there and only its declarations need care.
    """
    FUNCTION_CONSTRUCTOR = enum.auto()
    GLOBAL_EVAL = enum.auto()
    DIRECT_EVAL = enum.auto()


def _try_parse(code: str, *, top_level_await: bool) -> JsScript | None:
    try:
        from refinery.lib.scripts.js.parser import JsParser
        parsed = JsParser(code, top_level_await=top_level_await).parse()
    except Exception:
        return None
    if not parsed.body:
        return None
    return parsed


def _site_in_async_function(site: Node) -> bool:
    """
    Whether *site* sits inside an `async` function, so a direct `eval` there runs where `await` is an
    operator. Global-scope reflected code (indirect `eval`, a `Function` body, a string call) runs in
    the global sloppy scope instead, where `await` is an ordinary identifier and never an operator.
    """
    func = enclosing_function(site)
    return isinstance(func, FUNCTION_NODES) and func.is_async


def _try_eval_string_arg(node: Expression) -> str | None:
    from refinery.lib.scripts.js.deobfuscation.interpreter import (
        InterpreterError,
        IrreducibleExpression,
        JsInterpreter,
        _ThrowSignal,
    )
    try:
        result = JsInterpreter().eval_expression(node)
    except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError):
        return None
    if isinstance(result, str):
        return result
    return None


def _extract_eval_code(
    node: JsCallExpression,
    *,
    free_global_name: Callable[[Expression | None], str | None],
) -> str | None:
    """
    Extract the code string from a direct `eval("code")` / `(eval)("code")`. The callee must be the
    free global `eval`; a locally-shadowed `eval` names an ordinary value whose call is left intact.
    """
    if free_global_name(node.callee) != 'eval':
        return None
    if len(node.arguments) != 1:
        return None
    return string_value(node.arguments[0]) or _try_eval_string_arg(node.arguments[0])


def _extract_indirect_eval_code(
    node: JsCallExpression,
    read_effect: Callable[[Node], bool] | None = None,
    *,
    alias_name: Callable[[Expression | None], str | None],
    free_global_name: Callable[[Expression | None], str | None],
) -> str | None:
    """
    Extract the code string from indirect eval patterns:
    - `(0, eval)("code")`
    - `window.eval("code")` / `globalThis.eval("code")` / `window['eval']("code")`

    Inlining discards the comma-sequence prefix, so it is admitted only when dropping it is
    side-effect free; *read_effect* rejects a prefix read that resolves through a `with` body's dynamic
    scope (firing a getter or throwing), which the model-free check cannot see. *free_global_name*
    confirms the sequence tail is the free global `eval` and *alias_name* resolves a global-object-alias
    member to the intrinsic it names, both declining a shadowed name or a dynamic scope.
    """
    if len(node.arguments) != 1:
        return None
    callee = strip_parens(node.callee) if node.callee is not None else None
    if isinstance(callee, JsSequenceExpression):
        exprs = callee.expressions
        if len(exprs) >= 2 and free_global_name(exprs[-1]) == 'eval':
            if all(side_effect_free(e, read_effect=read_effect) for e in exprs[:-1]):
                return string_value(node.arguments[0]) or _try_eval_string_arg(node.arguments[0])
    if alias_name(node.callee) == 'eval':
        return string_value(node.arguments[0]) or _try_eval_string_arg(node.arguments[0])
    return None


def _extract_string_call_code(
    node: JsCallExpression,
    names: frozenset[str],
    *,
    alias_name: Callable[[Expression | None], str | None],
    free_global_name: Callable[[Expression | None], str | None],
) -> str | None:
    """
    Extract the code string a named global string-call evaluates — a deferred timer
    (`setTimeout("code", ...)`, `setInterval`, `setImmediate`) or a synchronous global eval
    (`execScript("code")`) — whether the global is named directly or through a global-object alias
    (`window.setTimeout("code", ...)`), both of which reach the same evaluating global. *names* selects
    which globals qualify. The callee must denote the free global: *free_global_name* resolves a bare
    name and *alias_name* a global-object-alias member, each declining a locally shadowed name or a
    dynamic scope.
    """
    if node.callee is None:
        return None
    name = free_global_name(node.callee) or alias_name(node.callee)
    if name not in names:
        return None
    if not node.arguments:
        return None
    return string_value(node.arguments[0]) or _try_eval_string_arg(node.arguments[0])


def _extract_function_body_code(
    constructor_call: JsCallExpression | JsNewExpression,
    *,
    free_global_name: Callable[[Expression | None], str | None],
) -> str | None:
    """
    Extract the body code string from Function constructor calls:

        Function("code")
        Function("a", "b", "code")
        new Function("code")

    The callee must be the free global `Function`; a locally-shadowed `Function` names an ordinary
    value and is left alone. The last string argument is the function body; preceding string arguments
    are parameter names (ignored for now).
    """
    if free_global_name(constructor_call.callee) != 'Function':
        return None
    args = constructor_call.arguments
    if not args:
        return None
    last = args[-1]
    body = string_value(last) or _try_eval_string_arg(last)
    if body is None:
        return None
    if not all(isinstance(a, JsStringLiteral) for a in args[:-1]):
        return None
    return body


def _is_constructor_chain(
    node: JsCallExpression | JsNewExpression, read_effect: Callable[[Node], bool] | None = None,
) -> bool:
    """
    Detect a constructor chain callee pattern equivalent to `Function`:

        <literal>.constructor.constructor

    Inlining discards the evaluation of the chain base, so the base must be side-effect free;
    *read_effect* rejects a bare-identifier base that resolves through a `with` body's dynamic scope
    (firing a getter or throwing), which the model-free check cannot see.
    """
    callee = node.callee
    if not isinstance(callee, JsMemberExpression):
        return False
    if access_key(callee) != 'constructor':
        return False
    inner = callee.object
    if not isinstance(inner, JsMemberExpression):
        return False
    if access_key(inner) != 'constructor':
        return False
    base = inner.object
    if base is None:
        return False
    return side_effect_free(base, read_effect=read_effect)


def _extract_constructor_chain_code(
    node: JsCallExpression, read_effect: Callable[[Node], bool] | None = None,
) -> str | None:
    """
    Extract code from constructor chain IIFE patterns:

        "".constructor.constructor("code")()
        [].constructor.constructor("code")()
    """
    inner_call = node.callee
    if not isinstance(inner_call, JsCallExpression):
        return None
    if not _is_constructor_chain(inner_call, read_effect):
        return None
    if len(inner_call.arguments) != 1:
        return None
    return string_value(inner_call.arguments[0]) or _try_eval_string_arg(inner_call.arguments[0])


def _invoked_function_constructor_code(
    node: JsCallExpression,
    read_effect: Callable[[Node], bool] | None = None,
    *,
    free_global_name: Callable[[Expression | None], str | None],
) -> tuple[str, bool] | None:
    """
    If *node* immediately invokes a `Function` constructor — `Function("code")()`,
    `new Function("code")()`, or the `<x>.constructor.constructor("code")()` chain — return its body
    code together with whether the constructor binds parameters or is passed call arguments. A
    `Function`-constructed function runs in the global scope with `this` bound to the global object and
    its parameters bound to the call arguments, so inlining its body into the caller is sound only when
    it binds neither.
    """
    inner = node.callee
    if isinstance(inner, (JsCallExpression, JsNewExpression)):
        code = _extract_function_body_code(inner, free_global_name=free_global_name)
        if code is not None:
            return code, len(inner.arguments) > 1 or bool(node.arguments)
    chain = _extract_constructor_chain_code(node, read_effect)
    if chain is not None:
        return chain, bool(node.arguments)
    return None


def _extract_getter_target(func: Expression | None) -> str | JsUnaryExpression | None:
    """
    Extract the value returned by a getter. Expected patterns:
    - `{ return <identifier>; }` -> returns the identifier name as `str`
    - a `typeof` expression -> returns a `refinery.lib.scripts.js.model.JsUnaryExpression` clone
    """
    if not isinstance(func, JsFunctionExpression):
        return None
    if func.body is None or not isinstance(func.body, JsBlockStatement):
        return None
    body = func.body.body
    if len(body) != 1:
        return None
    stmt = body[0]
    if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
        return None
    arg = stmt.argument
    if isinstance(arg, JsIdentifier):
        return arg.name
    if (
        isinstance(arg, JsUnaryExpression)
        and arg.operator == 'typeof'
        and isinstance(arg.operand, JsIdentifier)
    ):
        return arg
    return None


def _extract_setter_target(func: Expression | None) -> str | None:
    """
    Extract the global assigned in a setter. Expected pattern:

        { return <global> = <param>; }

    where the function has exactly one parameter.
    """
    if not isinstance(func, JsFunctionExpression):
        return None
    if len(func.params) != 1 or not isinstance(func.params[0], JsIdentifier):
        return None
    param_name = func.params[0].name
    if func.body is None or not isinstance(func.body, JsBlockStatement):
        return None
    body = func.body.body
    if len(body) != 1:
        return None
    stmt = body[0]
    if isinstance(stmt, JsReturnStatement):
        expr = stmt.argument
    elif isinstance(stmt, JsExpressionStatement):
        expr = stmt.expression
    else:
        return None
    if not isinstance(expr, JsAssignmentExpression) or expr.operator != '=':
        return None
    if not isinstance(expr.left, JsIdentifier):
        return None
    if not isinstance(expr.right, JsIdentifier) or expr.right.name != param_name:
        return None
    return expr.left.name


class _ProxyMapping(NamedTuple):
    getters: dict[str, str | JsUnaryExpression]
    setters: dict[str, str]


def _build_proxy_mapping(
    obj: JsObjectExpression,
) -> _ProxyMapping | None:
    """
    Build getter and setter mappings from a pack proxy object. Returns `(getters, setters)` or
    `None` if any property is malformed.
    """
    getters: dict[str, str | JsUnaryExpression] = {}
    setters: dict[str, str] = {}
    for prop in obj.properties:
        if not isinstance(prop, JsProperty):
            return None
        key = property_key(prop)
        if key is None:
            return None
        if prop.kind == JsPropertyKind.GET:
            target = _extract_getter_target(prop.value)
            if target is None:
                return None
            getters[key] = target
        elif prop.kind == JsPropertyKind.SET:
            target = _extract_setter_target(prop.value)
            if target is None:
                return None
            setters[key] = target
        else:
            return None
    return _ProxyMapping(getters, setters)


def _substitute_proxy_accesses(
    parsed: JsScript,
    param_name: str,
    getters: dict[str, str | JsUnaryExpression],
    setters: dict[str, str],
) -> bool:
    """
    Replace all `param[key]` accesses in the parsed code with the resolved globals from the proxy
    mapping. A plain read resolves to the getter target and a simple `key = v` write to the setter
    target; a compound, update, or delete access reads via the getter AND writes via the setter, which
    no single global substitution preserves, so it makes resolution fail. Returns `True` if every
    access was resolved successfully.
    """
    for node in list(parsed.walk()):
        if not isinstance(node, JsMemberExpression):
            continue
        if not isinstance(node.object, JsIdentifier) or node.object.name != param_name:
            continue
        key = access_key(node)
        if key is None:
            return False
        if is_simple_assignment_target(node):
            if key not in setters:
                return False
            _replace_in_parent(node, JsIdentifier(name=setters[key]))
        elif is_member_write_target(node):
            return False
        else:
            if key not in getters:
                return False
            target = getters[key]
            if isinstance(target, str):
                _replace_in_parent(node, JsIdentifier(name=target))
            else:
                _replace_in_parent(node, _clone_node(target))
    return True


def _try_unpack_function_constructor(
    node: JsCallExpression,
    *,
    free_global_name: Callable[[Expression | None], str | None],
) -> list[Statement] | None:
    """
    Unpack an immediately-invoked `Function` constructor whose single argument is a proxy object
    with getter/setter properties that redirect to global variables:

        Function("p", "p.abc = p.def(p.ghi)")(
            {get abc() { return x }, set abc(v) { x = v }, get def() { return y }, ...}
        )

    Parses the code string, resolves all `p.key` accesses through the proxy mapping back to their
    original global identifiers, and returns the recovered statement list. Returns `None` if the node
    does not match — including when the inner callee is not the free global `Function` — or if any
    proxy access cannot be resolved.
    """
    inner = node.callee
    if not isinstance(inner, JsCallExpression):
        return None
    if free_global_name(inner.callee) != 'Function':
        return None
    if len(node.arguments) != 1 or not isinstance(node.arguments[0], JsObjectExpression):
        return None
    proxy_obj = node.arguments[0]
    inner_args = inner.arguments
    if len(inner_args) == 1:
        param_name = ''
        code = string_value(inner_args[0])
    elif len(inner_args) == 2:
        param_name = string_value(inner_args[0])
        code = string_value(inner_args[1])
        if param_name is None:
            return None
    else:
        return None
    if code is None:
        return None
    mapping = _build_proxy_mapping(proxy_obj)
    if mapping is None:
        return None
    getters, setters = mapping
    parsed = _try_parse(code, top_level_await=False)
    if parsed is None:
        return None
    if param_name and not _substitute_proxy_accesses(parsed, param_name, getters, setters):
        return None
    return list(parsed.body)


def _is_pack_shaped(
    node: JsCallExpression,
    *,
    free_global_name: Callable[[Expression | None], str | None],
) -> bool:
    """
    Return `True` when the call has the shape of a pack pattern: the callee is a free-global `Function()`
    call and the outer argument is an object expression. When this shape is detected, the generic
    function-body extraction should be skipped to avoid inlining code with unresolved proxy references.
    The callee is identified through the model, so a locally shadowed `Function` is not mistaken for the
    intrinsic.
    """
    inner = node.callee
    if not isinstance(inner, JsCallExpression) or inner.callee is None:
        return False
    if free_global_name(inner.callee) != 'Function':
        return False
    return len(node.arguments) == 1 and isinstance(node.arguments[0], JsObjectExpression)


def _has_top_level_await(stmts: list[Statement]) -> bool:
    """
    Return `True` if any `refinery.lib.scripts.js.model.JsAwaitExpression` in `stmts` is at the top
    level, i.e. not inside a nested function boundary.
    """
    return any(isinstance(n, JsAwaitExpression) for s in stmts for n in walk_scope(s))


def _has_top_level_return(stmts: list[Statement]) -> bool:
    """
    Whether *stmts* — an evaluated code string's body — has a `return` at its own top level, outside any
    nested function. A `return` outside a function is a SyntaxError in `eval` and string-timer code, so
    such a body throws when evaluated and must not be inlined as if it produced a value or ran to
    completion. The `Function` constructor is exempt: its body is a real function body, where a
    top-level `return` is the function's own return.
    """
    return any(isinstance(n, JsReturnStatement) for s in stmts for n in walk_scope(s))


def _has_use_strict_directive(stmts: list[Statement]) -> bool:
    """
    Whether *stmts* opens with a `"use strict"` directive prologue. A strict `Function`-constructed
    body cannot be spliced into a possibly-sloppy caller without changing meaning: an assignment to an
    undeclared name throws under strict but silently creates a global under sloppy, and the directive
    stops governing the code once the body is no longer the first thing its scope runs.
    """
    for stmt in stmts:
        if not isinstance(stmt, JsExpressionStatement):
            return False
        if not isinstance(stmt.expression, JsStringLiteral):
            return False
        if stmt.expression.value == 'use strict':
            return True
    return False


def _site_in_strict_context(site: Node, root: JsScript) -> bool:
    """
    Whether *site* runs in strict mode, because the script opens with a `"use strict"` directive, an
    enclosing function does, or an enclosing class body (always strict) contains it. A
    `Function`-constructed body is always sloppy, so splicing it into a strict context could turn
    sloppy-only code — an octal literal, an unqualified `delete`, an assignment to an undeclared name
    or to `eval`/`arguments` — into a strict-mode SyntaxError or a behavior change; declining keeps
    the inlining sound.
    """
    if _has_use_strict_directive(root.body):
        return True
    cursor = site.parent
    while cursor is not None:
        if isinstance(cursor, (JsClassDeclaration, JsClassExpression)):
            return True
        if isinstance(cursor, FUNCTION_NODES):
            body = cursor.body
            if isinstance(body, JsBlockStatement) and _has_use_strict_directive(body.body):
                return True
        cursor = cursor.parent
    return False


def _references_new_target(root: Node) -> bool:
    """
    Whether *root* reads the `new.target` meta-property, which the parser models as a member access
    whose object is the reserved word `new`. A `Function`-constructed function is invoked as a call,
    so its `new.target` is always `undefined`; splicing the body into a real function would rebind
    `new.target` to the caller's, so a body that reads it cannot be inlined.
    """
    for node in root.walk():
        if (
            isinstance(node, JsMemberExpression)
            and isinstance(node.object, JsIdentifier)
            and node.object.name == 'new'
        ):
            return True
    return False


def _body_free_names(body_model: SemanticModel, parsed: JsScript) -> set[str]:
    """
    The names *parsed* reads or writes without binding them locally — the names a
    `Function`-constructed body resolves against the global scope. A name bound inside the body is
    excluded (inlining carries its binding along), as is a property name or key; an implicit-global
    write the body performs is included, since it targets a global rather than a local binding.
    """
    free: set[str] = set()
    for ident in parsed.walk():
        if not isinstance(ident, JsIdentifier) or not body_model.is_reference(ident):
            continue
        binding = body_model.resolve(ident)
        if binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL:
            free.add(ident.name)
    return free


def _body_declared_names(body_model: SemanticModel) -> set[str]:
    """
    The names a `Function`-constructed body declares at its top level — the `var`, function, `let`,
    `const`, and `class` bindings that inlining would hoist into the caller's scope. Implicit globals
    are excluded: those are writes to globals, covered by the free-name check rather than introduced as
    new bindings.
    """
    return {
        name for name, binding in body_model.root_scope.bindings.items()
        if binding.kind is not BindingKind.IMPLICIT_GLOBAL
    }


def _hoist_path_is_clear(names: set[str], site_scope: Scope, var_scope: Scope) -> bool:
    """
    Whether each hoisted `var`/function name can rise from the call site to *var_scope* without
    crossing a lexical binding of the same name. A `var` spliced into a block still hoists to the
    enclosing function or script, but it is a redeclaration SyntaxError if any block it passes
    through — from the site's own scope up to, but not including, *var_scope* — lexically binds the
    same name. Conflicts with a binding declared directly in *var_scope* are already caught by the
    capture check.
    """
    scope: Scope | None = site_scope
    while scope is not None and scope is not var_scope:
        if any(name in scope.bindings for name in names):
            return False
        scope = scope.parent
    return True


def _inlined_declarations_safe(
    body_model: SemanticModel,
    root_model: SemanticModel,
    site_scope: Scope,
) -> bool:
    """
    Whether the names a `Function`-constructed body declares at its top level can be introduced at the
    call site without capturing an identifier already meaningful there. Such declarations are local to
    the constructed function; inlining lifts `var` and function declarations into the caller's function
    or script scope and `let`/`const`/`class` into the caller's immediate block, where a same-named
    reference, an inherited binding, or a redeclaration would silently rebind to the inlined declaration
    or produce a duplicate lexical declaration. Each name is checked against the scope it would actually
    land in.
    """
    bindings = body_model.root_scope.bindings
    hoisted = {name for name, binding in bindings.items() if binding.is_hoisted}
    lexical = {name for name, binding in bindings.items() if binding.is_lexical}
    if hoisted:
        var_scope = site_scope.var_scope
        if var_scope is None or root_model.would_capture(hoisted, var_scope):
            return False
        if not _hoist_path_is_clear(hoisted, site_scope, var_scope):
            return False
    if lexical and root_model.would_capture(lexical, site_scope):
        return False
    return True


class JsReflectionInlining(ScriptLevelTransformer):
    """
    Inline reflective code execution: `eval`, `Function` constructor, constructor chains, and
    indirect invocation via `setTimeout` and `setInterval`.
    """

    _read_effect: Callable[[Node], bool]
    _alias_name: Callable[[Expression | None], str | None]
    _free_global: Callable[[Expression | None], str | None]

    def _process_script(self, node: JsScript) -> None:
        self._read_effect = self._dynamic_read_effect(node)
        self._alias_name = self._alias_member_name(node)
        self._free_global = self._free_global_name(node)
        self._inline_statements(node)
        self._inline_expressions(node)
        self._lower_timers(node)

    def _dynamic_read_effect(self, root: JsScript) -> Callable[[Node], bool]:
        """
        A predicate reporting whether reading a node crosses a `with` body's dynamic scope, resolved
        against *root*'s current model. Threaded into the reflective-inlining safety checks so a read
        that may fire a `with` object's getter or throw is never dropped as if it were pure. Resolved
        lazily through the shared cache, so a script with no reflective site builds no model.
        """
        return lambda node: model_cache(self, root).model.read_has_dynamic_effect(node)

    def _alias_member_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
        """
        A resolver reporting the intrinsic a global-object-alias member names — `window.eval` yields
        `'eval'`, `globalThis['setTimeout']` yields `'setTimeout'` — or `None` when the base is not the
        real, unshadowed global object. A local `window` (a parameter, a `var`, a `with`-object
        property) names an ordinary object whose member is not the reflective intrinsic and must not be
        inlined; the model's shadow- and dynamic-scope-aware check is the single source of that judgment.
        Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
        """
        def resolve(callee: Expression | None) -> str | None:
            if callee is None:
                return None
            member = strip_parens(callee)
            if not isinstance(member, JsMemberExpression):
                return None
            model = model_cache(self, root).model
            if model.scope_of(member) is None:
                return None
            return model.global_alias_member_name(member)
        return resolve

    def _free_global_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
        """
        A resolver reporting the reflective intrinsic a bare callee identifier denotes — `eval` yields
        `'eval'`, `Function` yields `'Function'`, a timer or `execScript` its own name — or `None`. Only
        a name that could name such a callee is resolved; any other identifier is declined before any
        model lookup, since no caller acts on a non-reflective name. A local binding (a parameter, a
        `var`, a `with`-object property) of the name is an ordinary value, not the intrinsic, and must
        not drive an inline; the model resolves a reference to its binding for a shadow and to `None` for
        a free global, and `read_has_dynamic_effect` rejects a name read through a dynamic scope.
        Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
        """
        def resolve(callee: Expression | None) -> str | None:
            if callee is None:
                return None
            ident = strip_parens(callee)
            if not isinstance(ident, JsIdentifier) or ident.name not in _REFLECTIVE_CALLEE_NAMES:
                return None
            model = model_cache(self, root).model
            if model.scope_of(ident) is None:
                return None
            if model.resolve(ident) is None and not model.read_has_dynamic_effect(ident):
                return ident.name
            return None
        return resolve

    def _inline_statements(self, root: JsScript) -> None:
        for container in list(root.walk()):
            body = get_body(container)
            if body is None:
                continue
            i = 0
            while i < len(body):
                parsed = self._try_resolve_statement(body[i], root, container is root)
                if parsed is None:
                    i += 1
                    continue
                parsed = self._sanitize_inlined_body(parsed)
                if parsed is None:
                    i += 1
                    continue
                for stmt in parsed:
                    stmt.parent = container
                body[i:i + 1] = parsed
                self.mark_changed()
                i += len(parsed)

    @staticmethod
    def _sanitize_inlined_body(stmts: list[Statement]) -> list[Statement] | None:
        """
        Adapt a reflective body's statements for the statement position they replace, where the call's
        return value is discarded and no `return` may escape into the container. A trailing `return x`
        becomes the bare expression `x` (its value was already being thrown away) and a trailing
        valueless `return` is dropped. Any other `return` — before the last statement, or nested in the
        control flow of any statement (an `if`, loop, or `try`) rather than at the body's own top level —
        declines the inlining (`None`), since its early exit cannot be reproduced at statement position
        without reordering and declining is always sound. `walk_scope` finds a nested `return` without
        descending into a nested function, whose own `return` stays with it. This holds for every
        container, not only the script: a `return` spliced into a function body would return from that
        enclosing function, and into the script would be a syntax error.
        """
        if not stmts:
            return stmts
        trailing = stmts[-1] if isinstance(stmts[-1], JsReturnStatement) else None
        for stmt in stmts[:-1] if trailing is not None else stmts:
            if any(isinstance(node, JsReturnStatement) for node in walk_scope(stmt)):
                return None
        if trailing is None:
            return stmts
        if trailing.argument is not None:
            return [*stmts[:-1], JsExpressionStatement(expression=trailing.argument)]
        return stmts[:-1]

    def _inline_expressions(self, root: JsScript) -> None:
        for node in list(root.walk()):
            if not isinstance(node, JsCallExpression):
                continue
            if isinstance(node.parent, JsExpressionStatement):
                continue
            replacement = self._try_resolve_expression(node, root)
            if replacement is None:
                continue
            _replace_in_parent(node, replacement)
            self.mark_changed()

    def _lower_timers(self, root: JsScript) -> None:
        """
        Rewrite a string-argument timer — `setTimeout("code", delay)`, `setInterval`, and their
        `setImmediate`/global-alias variants — into a deferred function call
        `setTimeout(function () { code }, delay)`, so the evaluated code is deobfuscated without changing
        when or how often it runs. Unlike the eval and constructor paths, a timer is not inlined at the
        call site: its value is a handle and its execution is deferred, so only its code string is
        lowered. `execScript` is not a timer — it evaluates synchronously — so it is inlined in place by
        `_try_resolve_statement` instead of lowered here.
        """
        for node in list(root.walk()):
            if isinstance(node, JsCallExpression):
                self._try_lower_timer(node, root)

    def _try_lower_timer(self, node: JsCallExpression, root: JsScript) -> None:
        """
        Replace a string timer's code argument with a function wrapping the parsed code, when that code
        runs safely in the global scope the timer would give it. The wrapper is defined at the call site,
        so it is held to the same global-scope safety as an indirect eval — its `this` is rewritten to
        `globalThis`, its free names must still denote the same global, and a top-level declaration
        (whose global or transient environment a local function cannot reproduce) or a `return`/`await`
        that a plain function body cannot host declines the lowering, leaving the string timer intact.
        """
        code = _extract_string_call_code(
            node,
            TIMER_NAMES,
            alias_name=self._alias_name,
            free_global_name=self._free_global,
        )
        if code is None:
            return
        resolved = self._resolve_reflected_body(
            code, node, root, ReflectedScope.GLOBAL_EVAL, at_global_scope=False,
        )
        if resolved is None or _has_top_level_await(resolved.body):
            return
        block = JsBlockStatement(body=resolved.body)
        wrapper = JsFunctionExpression(params=[], body=block)
        block.parent = wrapper
        for stmt in resolved.body:
            stmt.parent = block
        _replace_in_parent(node.arguments[0], wrapper)
        self.mark_changed()

    def _try_resolve_statement(
        self, stmt: Statement, root: JsScript, at_global_scope: bool,
    ) -> list[Statement] | None:
        """
        Resolve a statement-position reflective call to the statements it should become, or `None`. A
        `Function`-constructor pack, a direct or indirect `eval`, and a `Function` body are handled by
        `_resolve_reflected_call`; `execScript("code")` runs its code synchronously in the global scope
        and discards the value, so at statement position it is replaced by that code inlined in place. An
        `await`-ed call is not a plain call expression here, so it is left for the expression pass, which
        rewrites the `eval` inside `await eval("expr")` to `await (expr)` without dropping the `await`.
        """
        if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
            return None
        node = stmt.expression
        if not isinstance(node, JsCallExpression):
            return None
        sync = _extract_string_call_code(
            node,
            SYNC_EVAL_NAMES,
            alias_name=self._alias_name,
            free_global_name=self._free_global,
        )
        if sync is not None:
            parsed = self._resolve_reflected_body(
                sync, stmt, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
            )
            if parsed is None or _has_top_level_await(parsed.body):
                return None
            return parsed.body
        pack_result = _try_unpack_function_constructor(
            node, free_global_name=self._free_global)
        if pack_result is not None:
            return pack_result
        if _is_pack_shaped(node, free_global_name=self._free_global):
            return None
        resolved = self._resolve_reflected_call(node, stmt, root, at_global_scope)
        if resolved is None:
            return None
        return resolved[1].body

    def _try_resolve_expression(self, node: JsCallExpression, root: JsScript) -> Expression | None:
        resolved = self._resolve_reflected_call(node, node, root, at_global_scope=False)
        if resolved is None:
            return None
        scope, parsed = resolved
        body = parsed.body
        if len(body) != 1:
            return None
        stmt = body[0]
        if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
            if isinstance(stmt, JsReturnStatement) and stmt.argument is not None:
                return stmt.argument
            return None
        if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None:
            return stmt.expression
        return None

    def _resolve_reflected_call(
        self,
        node: JsCallExpression,
        site: Node,
        root: JsScript,
        at_global_scope: bool,
    ) -> tuple[ReflectedScope, JsScript] | None:
        """
        Dispatch a reflective call to the safety gate for its execution scope, pairing the resolved body
        with that scope or returning `None` to decline. A `Function` constructor or constructor chain is
        a fresh global-scope function; a direct `eval` runs in the caller's scope; an indirect `eval`
        runs in the global scope. A string timer is not inlined here: its value is a handle, not the
        code's completion value, and its deferred execution is preserved instead by `_lower_timers`.
        """
        read_effect = self._read_effect
        alias_name = self._alias_name
        free_global_name = self._free_global
        constructor = _invoked_function_constructor_code(
            node, read_effect, free_global_name=free_global_name)
        if constructor is not None:
            code, binds = constructor
            parsed = self._resolve_reflected_body(
                code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope, binds=binds,
            )
            return (ReflectedScope.FUNCTION_CONSTRUCTOR, parsed) if parsed is not None else None
        direct = _extract_eval_code(node, free_global_name=free_global_name)
        if direct is not None:
            parsed = self._resolve_reflected_body(
                direct, site, root, ReflectedScope.DIRECT_EVAL, at_global_scope,
            )
            return (ReflectedScope.DIRECT_EVAL, parsed) if parsed is not None else None
        code = _extract_indirect_eval_code(
            node, read_effect, alias_name=alias_name, free_global_name=free_global_name)
        if code is not None:
            parsed = self._resolve_reflected_body(
                code, site, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
            )
            return (ReflectedScope.GLOBAL_EVAL, parsed) if parsed is not None else None
        return None

    def _resolve_reflected_body(
        self,
        code: str,
        site: Node,
        root: JsScript,
        scope: ReflectedScope,
        at_global_scope: bool,
        *,
        binds: bool = False,
    ) -> JsScript | None:
        """
        Parse reflectively evaluated *code* and decide whether inlining its body at *site* preserves
        meaning, given the `ReflectedScope` it runs in. Global-scope code — a `Function`-constructed
        body or indirect `eval`/string-timer code — must run in the global sloppy mode it would have: a
        strict context at *site* declines a body that would diverge under strict mode
        (`diverges_under_strict`), as does a `"use strict"` prologue; every receiver `this` becomes
        `globalThis`; and a body reading `arguments`, `super`, or `new.target`, or a free
        name that no longer denotes the same global at *site* — including one a `with` on the path could
        capture — declines. Direct `eval` runs in the caller's scope, which is *site* itself, so its
        references and `this` are already correct there and only the checks below apply. A top-level
        `return` is a SyntaxError in evaluated code, so an eval body with one declines. Declaration
        handling is delegated to `_reflected_declarations_safe`. Anything not provably safe is left
        intact (returns `None`) — declining is always sound.
        """
        if binds:
            return None
        resolves_globally = scope is not ReflectedScope.DIRECT_EVAL
        top_level_await = not resolves_globally and _site_in_async_function(site)
        parsed = _try_parse(code, top_level_await=top_level_await)
        if parsed is None:
            return None
        if _has_use_strict_directive(parsed.body) and (
            resolves_globally or not _site_in_strict_context(site, root)
        ):
            return None
        if resolves_globally:
            rewrite_receiver_this_to_global(parsed)
            if references_receiver_this(parsed) or _references_new_target(parsed):
                return None
        if scope is not ReflectedScope.FUNCTION_CONSTRUCTOR and _has_top_level_return(parsed.body):
            return None
        body_model = build_semantic_model(parsed)
        if (
            resolves_globally
            and _site_in_strict_context(site, root)
            and diverges_under_strict(parsed, body_model)
        ):
            return None
        free = _body_free_names(body_model, parsed)
        if resolves_globally and 'arguments' in free:
            return None
        declared = _body_declared_names(body_model)
        if not free and not declared:
            return parsed
        root_model = model_cache(self, root).model
        site_scope = root_model.scope_of(site)
        if site_scope is None:
            return None
        if resolves_globally and free:
            if crosses_dynamic_scope(site_scope):
                return None
            for name in free:
                binding = root_model.lookup(name, site_scope)
                if binding is not None and not root_model.reaches_global_object(
                    binding, module_scope=module_execution(self.options),
                ):
                    return None
        if declared and not self._reflected_declarations_safe(
            body_model, root_model, site_scope, site, scope, at_global_scope,
        ):
            return None
        return parsed

    def _reflected_declarations_safe(
        self,
        body_model: SemanticModel,
        root_model: SemanticModel,
        site_scope: Scope,
        site: Node,
        scope: ReflectedScope,
        at_global_scope: bool,
    ) -> bool:
        """
        Whether the top-level declarations of a reflected body can be reproduced by inlining it at the
        call site. A `Function`-constructed body's declarations are local to the created function and
        lift into the caller's scopes (`_inlined_declarations_safe`); evaluated code declares in its
        execution scope and is handled by `_eval_declarations_safe`.
        """
        if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
            return _inlined_declarations_safe(body_model, root_model, site_scope)
        return self._eval_declarations_safe(
            body_model, root_model, site_scope, site, scope, at_global_scope,
        )

    def _eval_declarations_safe(
        self,
        body_model: SemanticModel,
        root_model: SemanticModel,
        site_scope: Scope,
        site: Node,
        scope: ReflectedScope,
        at_global_scope: bool,
    ) -> bool:
        """
        Whether an `eval` body's top-level declarations can be inlined at the call site. A
        `let`/`const`/`class` lives in a declarative environment discarded when the evaluation
        returns, so a persistent inlined binding differs only if a name it declares is referenced
        outside the body; it is declined exactly when introducing it at the site would capture such a
        reference. A `var` or function persists: under indirect eval it becomes a global-object
        property, reproducible only at top-level script scope and never under the module model; under
        direct eval it lands in the caller's variable scope, but never under a strict direct eval,
        whose `var` stays local to the eval. Such a declaration hoists to the head of its variable
        scope, so it is inlined only when the eval site strictly dominates every reference to the name
        already there — one that runs before it or shares its statement, or reads the name through a
        closure, would be rebound.
        """
        root = root_model.root
        bindings = body_model.root_scope.bindings
        lexical = {name for name, binding in bindings.items() if binding.is_lexical}
        if lexical and root_model.would_capture(lexical, site_scope):
            return False
        hoisted = {name for name, binding in bindings.items() if binding.is_hoisted}
        if not hoisted:
            return True
        if scope is ReflectedScope.GLOBAL_EVAL:
            if module_execution(self.options) or not at_global_scope:
                return False
        elif _site_in_strict_context(site, root) or _has_use_strict_directive(body_model.root.body):
            return False
        var_scope = site_scope.var_scope
        if var_scope is None:
            return False
        dominance = model_cache(self, root).dominance
        return all(
            dominance.strictly_dominates(site, node)
            for node in name_uses_in_scope(hoisted, var_scope)
        )

Classes

class ReflectedScope (*args, **kwds)

The execution scope of reflectively evaluated code, which decides how its free names, this, and top-level declarations must be treated when the code is inlined at its call site. A Function-constructed function and indirect eval/string-timer code run in the global sloppy scope; a direct eval runs in the caller's scope, which is the inline site itself, so its references and this are already correct there and only its declarations need care.

Expand source code Browse git
class ReflectedScope(enum.Enum):
    """
    The execution scope of reflectively evaluated code, which decides how its free names, `this`, and
    top-level declarations must be treated when the code is inlined at its call site. A
    `Function`-constructed function and indirect `eval`/string-timer code run in the global sloppy
    scope; a direct `eval` runs in the caller's scope, which is the inline site itself, so its
    references and `this` are already correct there and only its declarations need care.
    """
    FUNCTION_CONSTRUCTOR = enum.auto()
    GLOBAL_EVAL = enum.auto()
    DIRECT_EVAL = enum.auto()

Ancestors

  • enum.Enum

Class variables

var FUNCTION_CONSTRUCTOR

The type of the None singleton.

var GLOBAL_EVAL

The type of the None singleton.

var DIRECT_EVAL

The type of the None singleton.

class JsReflectionInlining

Inline reflective code execution: eval, Function constructor, constructor chains, and indirect invocation via setTimeout and setInterval.

Expand source code Browse git
class JsReflectionInlining(ScriptLevelTransformer):
    """
    Inline reflective code execution: `eval`, `Function` constructor, constructor chains, and
    indirect invocation via `setTimeout` and `setInterval`.
    """

    _read_effect: Callable[[Node], bool]
    _alias_name: Callable[[Expression | None], str | None]
    _free_global: Callable[[Expression | None], str | None]

    def _process_script(self, node: JsScript) -> None:
        self._read_effect = self._dynamic_read_effect(node)
        self._alias_name = self._alias_member_name(node)
        self._free_global = self._free_global_name(node)
        self._inline_statements(node)
        self._inline_expressions(node)
        self._lower_timers(node)

    def _dynamic_read_effect(self, root: JsScript) -> Callable[[Node], bool]:
        """
        A predicate reporting whether reading a node crosses a `with` body's dynamic scope, resolved
        against *root*'s current model. Threaded into the reflective-inlining safety checks so a read
        that may fire a `with` object's getter or throw is never dropped as if it were pure. Resolved
        lazily through the shared cache, so a script with no reflective site builds no model.
        """
        return lambda node: model_cache(self, root).model.read_has_dynamic_effect(node)

    def _alias_member_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
        """
        A resolver reporting the intrinsic a global-object-alias member names — `window.eval` yields
        `'eval'`, `globalThis['setTimeout']` yields `'setTimeout'` — or `None` when the base is not the
        real, unshadowed global object. A local `window` (a parameter, a `var`, a `with`-object
        property) names an ordinary object whose member is not the reflective intrinsic and must not be
        inlined; the model's shadow- and dynamic-scope-aware check is the single source of that judgment.
        Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
        """
        def resolve(callee: Expression | None) -> str | None:
            if callee is None:
                return None
            member = strip_parens(callee)
            if not isinstance(member, JsMemberExpression):
                return None
            model = model_cache(self, root).model
            if model.scope_of(member) is None:
                return None
            return model.global_alias_member_name(member)
        return resolve

    def _free_global_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
        """
        A resolver reporting the reflective intrinsic a bare callee identifier denotes — `eval` yields
        `'eval'`, `Function` yields `'Function'`, a timer or `execScript` its own name — or `None`. Only
        a name that could name such a callee is resolved; any other identifier is declined before any
        model lookup, since no caller acts on a non-reflective name. A local binding (a parameter, a
        `var`, a `with`-object property) of the name is an ordinary value, not the intrinsic, and must
        not drive an inline; the model resolves a reference to its binding for a shadow and to `None` for
        a free global, and `read_has_dynamic_effect` rejects a name read through a dynamic scope.
        Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
        """
        def resolve(callee: Expression | None) -> str | None:
            if callee is None:
                return None
            ident = strip_parens(callee)
            if not isinstance(ident, JsIdentifier) or ident.name not in _REFLECTIVE_CALLEE_NAMES:
                return None
            model = model_cache(self, root).model
            if model.scope_of(ident) is None:
                return None
            if model.resolve(ident) is None and not model.read_has_dynamic_effect(ident):
                return ident.name
            return None
        return resolve

    def _inline_statements(self, root: JsScript) -> None:
        for container in list(root.walk()):
            body = get_body(container)
            if body is None:
                continue
            i = 0
            while i < len(body):
                parsed = self._try_resolve_statement(body[i], root, container is root)
                if parsed is None:
                    i += 1
                    continue
                parsed = self._sanitize_inlined_body(parsed)
                if parsed is None:
                    i += 1
                    continue
                for stmt in parsed:
                    stmt.parent = container
                body[i:i + 1] = parsed
                self.mark_changed()
                i += len(parsed)

    @staticmethod
    def _sanitize_inlined_body(stmts: list[Statement]) -> list[Statement] | None:
        """
        Adapt a reflective body's statements for the statement position they replace, where the call's
        return value is discarded and no `return` may escape into the container. A trailing `return x`
        becomes the bare expression `x` (its value was already being thrown away) and a trailing
        valueless `return` is dropped. Any other `return` — before the last statement, or nested in the
        control flow of any statement (an `if`, loop, or `try`) rather than at the body's own top level —
        declines the inlining (`None`), since its early exit cannot be reproduced at statement position
        without reordering and declining is always sound. `walk_scope` finds a nested `return` without
        descending into a nested function, whose own `return` stays with it. This holds for every
        container, not only the script: a `return` spliced into a function body would return from that
        enclosing function, and into the script would be a syntax error.
        """
        if not stmts:
            return stmts
        trailing = stmts[-1] if isinstance(stmts[-1], JsReturnStatement) else None
        for stmt in stmts[:-1] if trailing is not None else stmts:
            if any(isinstance(node, JsReturnStatement) for node in walk_scope(stmt)):
                return None
        if trailing is None:
            return stmts
        if trailing.argument is not None:
            return [*stmts[:-1], JsExpressionStatement(expression=trailing.argument)]
        return stmts[:-1]

    def _inline_expressions(self, root: JsScript) -> None:
        for node in list(root.walk()):
            if not isinstance(node, JsCallExpression):
                continue
            if isinstance(node.parent, JsExpressionStatement):
                continue
            replacement = self._try_resolve_expression(node, root)
            if replacement is None:
                continue
            _replace_in_parent(node, replacement)
            self.mark_changed()

    def _lower_timers(self, root: JsScript) -> None:
        """
        Rewrite a string-argument timer — `setTimeout("code", delay)`, `setInterval`, and their
        `setImmediate`/global-alias variants — into a deferred function call
        `setTimeout(function () { code }, delay)`, so the evaluated code is deobfuscated without changing
        when or how often it runs. Unlike the eval and constructor paths, a timer is not inlined at the
        call site: its value is a handle and its execution is deferred, so only its code string is
        lowered. `execScript` is not a timer — it evaluates synchronously — so it is inlined in place by
        `_try_resolve_statement` instead of lowered here.
        """
        for node in list(root.walk()):
            if isinstance(node, JsCallExpression):
                self._try_lower_timer(node, root)

    def _try_lower_timer(self, node: JsCallExpression, root: JsScript) -> None:
        """
        Replace a string timer's code argument with a function wrapping the parsed code, when that code
        runs safely in the global scope the timer would give it. The wrapper is defined at the call site,
        so it is held to the same global-scope safety as an indirect eval — its `this` is rewritten to
        `globalThis`, its free names must still denote the same global, and a top-level declaration
        (whose global or transient environment a local function cannot reproduce) or a `return`/`await`
        that a plain function body cannot host declines the lowering, leaving the string timer intact.
        """
        code = _extract_string_call_code(
            node,
            TIMER_NAMES,
            alias_name=self._alias_name,
            free_global_name=self._free_global,
        )
        if code is None:
            return
        resolved = self._resolve_reflected_body(
            code, node, root, ReflectedScope.GLOBAL_EVAL, at_global_scope=False,
        )
        if resolved is None or _has_top_level_await(resolved.body):
            return
        block = JsBlockStatement(body=resolved.body)
        wrapper = JsFunctionExpression(params=[], body=block)
        block.parent = wrapper
        for stmt in resolved.body:
            stmt.parent = block
        _replace_in_parent(node.arguments[0], wrapper)
        self.mark_changed()

    def _try_resolve_statement(
        self, stmt: Statement, root: JsScript, at_global_scope: bool,
    ) -> list[Statement] | None:
        """
        Resolve a statement-position reflective call to the statements it should become, or `None`. A
        `Function`-constructor pack, a direct or indirect `eval`, and a `Function` body are handled by
        `_resolve_reflected_call`; `execScript("code")` runs its code synchronously in the global scope
        and discards the value, so at statement position it is replaced by that code inlined in place. An
        `await`-ed call is not a plain call expression here, so it is left for the expression pass, which
        rewrites the `eval` inside `await eval("expr")` to `await (expr)` without dropping the `await`.
        """
        if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
            return None
        node = stmt.expression
        if not isinstance(node, JsCallExpression):
            return None
        sync = _extract_string_call_code(
            node,
            SYNC_EVAL_NAMES,
            alias_name=self._alias_name,
            free_global_name=self._free_global,
        )
        if sync is not None:
            parsed = self._resolve_reflected_body(
                sync, stmt, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
            )
            if parsed is None or _has_top_level_await(parsed.body):
                return None
            return parsed.body
        pack_result = _try_unpack_function_constructor(
            node, free_global_name=self._free_global)
        if pack_result is not None:
            return pack_result
        if _is_pack_shaped(node, free_global_name=self._free_global):
            return None
        resolved = self._resolve_reflected_call(node, stmt, root, at_global_scope)
        if resolved is None:
            return None
        return resolved[1].body

    def _try_resolve_expression(self, node: JsCallExpression, root: JsScript) -> Expression | None:
        resolved = self._resolve_reflected_call(node, node, root, at_global_scope=False)
        if resolved is None:
            return None
        scope, parsed = resolved
        body = parsed.body
        if len(body) != 1:
            return None
        stmt = body[0]
        if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
            if isinstance(stmt, JsReturnStatement) and stmt.argument is not None:
                return stmt.argument
            return None
        if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None:
            return stmt.expression
        return None

    def _resolve_reflected_call(
        self,
        node: JsCallExpression,
        site: Node,
        root: JsScript,
        at_global_scope: bool,
    ) -> tuple[ReflectedScope, JsScript] | None:
        """
        Dispatch a reflective call to the safety gate for its execution scope, pairing the resolved body
        with that scope or returning `None` to decline. A `Function` constructor or constructor chain is
        a fresh global-scope function; a direct `eval` runs in the caller's scope; an indirect `eval`
        runs in the global scope. A string timer is not inlined here: its value is a handle, not the
        code's completion value, and its deferred execution is preserved instead by `_lower_timers`.
        """
        read_effect = self._read_effect
        alias_name = self._alias_name
        free_global_name = self._free_global
        constructor = _invoked_function_constructor_code(
            node, read_effect, free_global_name=free_global_name)
        if constructor is not None:
            code, binds = constructor
            parsed = self._resolve_reflected_body(
                code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope, binds=binds,
            )
            return (ReflectedScope.FUNCTION_CONSTRUCTOR, parsed) if parsed is not None else None
        direct = _extract_eval_code(node, free_global_name=free_global_name)
        if direct is not None:
            parsed = self._resolve_reflected_body(
                direct, site, root, ReflectedScope.DIRECT_EVAL, at_global_scope,
            )
            return (ReflectedScope.DIRECT_EVAL, parsed) if parsed is not None else None
        code = _extract_indirect_eval_code(
            node, read_effect, alias_name=alias_name, free_global_name=free_global_name)
        if code is not None:
            parsed = self._resolve_reflected_body(
                code, site, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
            )
            return (ReflectedScope.GLOBAL_EVAL, parsed) if parsed is not None else None
        return None

    def _resolve_reflected_body(
        self,
        code: str,
        site: Node,
        root: JsScript,
        scope: ReflectedScope,
        at_global_scope: bool,
        *,
        binds: bool = False,
    ) -> JsScript | None:
        """
        Parse reflectively evaluated *code* and decide whether inlining its body at *site* preserves
        meaning, given the `ReflectedScope` it runs in. Global-scope code — a `Function`-constructed
        body or indirect `eval`/string-timer code — must run in the global sloppy mode it would have: a
        strict context at *site* declines a body that would diverge under strict mode
        (`diverges_under_strict`), as does a `"use strict"` prologue; every receiver `this` becomes
        `globalThis`; and a body reading `arguments`, `super`, or `new.target`, or a free
        name that no longer denotes the same global at *site* — including one a `with` on the path could
        capture — declines. Direct `eval` runs in the caller's scope, which is *site* itself, so its
        references and `this` are already correct there and only the checks below apply. A top-level
        `return` is a SyntaxError in evaluated code, so an eval body with one declines. Declaration
        handling is delegated to `_reflected_declarations_safe`. Anything not provably safe is left
        intact (returns `None`) — declining is always sound.
        """
        if binds:
            return None
        resolves_globally = scope is not ReflectedScope.DIRECT_EVAL
        top_level_await = not resolves_globally and _site_in_async_function(site)
        parsed = _try_parse(code, top_level_await=top_level_await)
        if parsed is None:
            return None
        if _has_use_strict_directive(parsed.body) and (
            resolves_globally or not _site_in_strict_context(site, root)
        ):
            return None
        if resolves_globally:
            rewrite_receiver_this_to_global(parsed)
            if references_receiver_this(parsed) or _references_new_target(parsed):
                return None
        if scope is not ReflectedScope.FUNCTION_CONSTRUCTOR and _has_top_level_return(parsed.body):
            return None
        body_model = build_semantic_model(parsed)
        if (
            resolves_globally
            and _site_in_strict_context(site, root)
            and diverges_under_strict(parsed, body_model)
        ):
            return None
        free = _body_free_names(body_model, parsed)
        if resolves_globally and 'arguments' in free:
            return None
        declared = _body_declared_names(body_model)
        if not free and not declared:
            return parsed
        root_model = model_cache(self, root).model
        site_scope = root_model.scope_of(site)
        if site_scope is None:
            return None
        if resolves_globally and free:
            if crosses_dynamic_scope(site_scope):
                return None
            for name in free:
                binding = root_model.lookup(name, site_scope)
                if binding is not None and not root_model.reaches_global_object(
                    binding, module_scope=module_execution(self.options),
                ):
                    return None
        if declared and not self._reflected_declarations_safe(
            body_model, root_model, site_scope, site, scope, at_global_scope,
        ):
            return None
        return parsed

    def _reflected_declarations_safe(
        self,
        body_model: SemanticModel,
        root_model: SemanticModel,
        site_scope: Scope,
        site: Node,
        scope: ReflectedScope,
        at_global_scope: bool,
    ) -> bool:
        """
        Whether the top-level declarations of a reflected body can be reproduced by inlining it at the
        call site. A `Function`-constructed body's declarations are local to the created function and
        lift into the caller's scopes (`_inlined_declarations_safe`); evaluated code declares in its
        execution scope and is handled by `_eval_declarations_safe`.
        """
        if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
            return _inlined_declarations_safe(body_model, root_model, site_scope)
        return self._eval_declarations_safe(
            body_model, root_model, site_scope, site, scope, at_global_scope,
        )

    def _eval_declarations_safe(
        self,
        body_model: SemanticModel,
        root_model: SemanticModel,
        site_scope: Scope,
        site: Node,
        scope: ReflectedScope,
        at_global_scope: bool,
    ) -> bool:
        """
        Whether an `eval` body's top-level declarations can be inlined at the call site. A
        `let`/`const`/`class` lives in a declarative environment discarded when the evaluation
        returns, so a persistent inlined binding differs only if a name it declares is referenced
        outside the body; it is declined exactly when introducing it at the site would capture such a
        reference. A `var` or function persists: under indirect eval it becomes a global-object
        property, reproducible only at top-level script scope and never under the module model; under
        direct eval it lands in the caller's variable scope, but never under a strict direct eval,
        whose `var` stays local to the eval. Such a declaration hoists to the head of its variable
        scope, so it is inlined only when the eval site strictly dominates every reference to the name
        already there — one that runs before it or shares its statement, or reads the name through a
        closure, would be rebound.
        """
        root = root_model.root
        bindings = body_model.root_scope.bindings
        lexical = {name for name, binding in bindings.items() if binding.is_lexical}
        if lexical and root_model.would_capture(lexical, site_scope):
            return False
        hoisted = {name for name, binding in bindings.items() if binding.is_hoisted}
        if not hoisted:
            return True
        if scope is ReflectedScope.GLOBAL_EVAL:
            if module_execution(self.options) or not at_global_scope:
                return False
        elif _site_in_strict_context(site, root) or _has_use_strict_directive(body_model.root.body):
            return False
        var_scope = site_scope.var_scope
        if var_scope is None:
            return False
        dominance = model_cache(self, root).dominance
        return all(
            dominance.strictly_dominates(site, node)
            for node in name_uses_in_scope(hoisted, var_scope)
        )

Ancestors

Inherited members