Module refinery.lib.scripts.js.deobfuscation.evaluator

Evaluate pure JavaScript functions called with constant arguments and replace call sites with computed results.

Expand source code Browse git
"""
Evaluate pure JavaScript functions called with constant arguments and replace call sites with
computed results.
"""
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from refinery.lib.scripts.js.values import Value

from refinery.lib.scripts import Node, Transformer, _remove_from_parent, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    SemanticModel,
    pattern_identifiers,
)
from refinery.lib.scripts.js.deobfuscation.admission import CallAdmission, is_evaluable
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    binding_has_references,
    extract_literal_value,
    remove_declarator,
    replace_with_value,
    substitute_params,
    value_to_node,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import (
    InterpreterError,
    IrreducibleExpression,
    JsInterpreter,
)
from refinery.lib.scripts.js.model import (
    JsArrowFunctionExpression,
    JsCallExpression,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsMemberExpression,
    JsScript,
    JsVariableDeclarator,
    strip_parens,
)

_FuncNode = JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression


class JsFunctionEvaluator(ScriptLevelTransformer):
    """
    Evaluate pure JavaScript functions called with constant arguments, replacing call sites with
    computed results. Handles named function calls and IIFEs.
    """

    def __init__(self):
        super().__init__()
        self._script: JsScript | None = None
        self._call_counts: dict[int, int] = {}
        self._resolved_counts: dict[int, int] = {}
        self._failed_counts: dict[int, int] = {}

    def _process_script(self, node: JsScript) -> None:
        """
        Decide every fold of one invocation against the model snapshot it opened with, holding the
        cache pinned through analysis and evaluation. The pin's obligation holds in one direction
        only, and every read obeys it: a fold replaces a call with its value or a substituted clone
        of a body expression, which only ever deletes — a callee reference, the effects the call
        carried, a tampering site it contained — so each held answer stays the stricter one and a
        later fold declines where a fresh model might have proceeded. The dominance a fold reads to
        know the callee's value is established before the call is a statement-order fact, which no
        expression replacement moves, and a spliced clone lands exactly where the call stood and
        carries only names `_substitution_would_break` has already bound identically at that spot,
        so no held fact is revealed more permissive by the splice. The held model has never
        resolved the names of such a clone, so a later fold running the code that holds it reads
        none of them off the model and declines instead.

        The removal of resolved definitions runs after the pin is released, against the model the
        cache rebuilds over the post-evaluation tree: whether anything still names a function is a
        structural fact the folds themselves change — each one deletes the references that kept
        the callee alive, and a spliced clone can name a binding the entry snapshot never saw —
        so this decision cannot be made against the held model without deleting a live function
        or holding a dead one back forever.
        """
        self._script = node
        self._call_counts.clear()
        self._resolved_counts.clear()
        self._failed_counts.clear()
        cache = model_cache(self, node)
        with cache.pinned():
            admission = CallAdmission(cache, node)
            self._evaluate_calls(node, admission)
        self._remove_resolved_definitions(node, admission)

    def _evaluate_calls(self, script: JsScript, admission: CallAdmission) -> None:
        for node in list(script.walk_in_order()):
            if not isinstance(node, JsCallExpression):
                continue
            if node.callee is None:
                continue
            callee = strip_parens(node.callee)
            if isinstance(callee, JsIdentifier):
                self._try_named_call(node, admission)
            elif isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
                self._try_iife(node, callee, admission)
            elif isinstance(callee, JsMemberExpression):
                self._try_method_chain(node, admission)

    def _try_method_chain(self, node: JsCallExpression, admission: CallAdmission) -> None:
        """
        Evaluate a method call whose value the interpreter can compute — the `[66, 79].map(f).join('')`
        decoder shape and everything it composes from. Unlike a call to a named function, there is no
        function node to run with arguments: the call *is* the expression, so it goes to
        `eval_expression` and the result replaces it.

        Walking outermost-first means the longest chain is attempted before any of its links, so a
        whole decoder collapses in one step and the inner links are gone before they are reached.
        Admission is the shared gate's decision alone, asked at the call
        (`refinery.lib.scripts.js.analysis.cache.ModelCache.trust_at`); this adds no question of its
        own, because every one it would ask — is the prototype intact, does a callback write outside
        itself, is an argument effectful — the gate already asks.
        """
        known, result = admission.method_chain_value(node)
        if known and replace_with_value(node, result):
            self.mark_changed()

    def _try_named_call(self, node: JsCallExpression, admission: CallAdmission) -> None:
        func = admission.callee(node)
        if func is None:
            return
        func_id = id(func)
        self._call_counts[func_id] = self._call_counts.get(func_id, 0) + 1
        interpreter = admission.interpreter_at(node)
        args = self._extract_constant_args(node.arguments, interpreter)
        if args is None:
            return
        success = self._evaluate_and_replace(
            node, func, args, interpreter, admission.effects, gate_unresolved=True
        )
        if success:
            self._resolved_counts[func_id] = self._resolved_counts.get(func_id, 0) + 1
        else:
            self._failed_counts[func_id] = self._failed_counts.get(func_id, 0) + 1

    def _try_iife(
        self,
        node: JsCallExpression,
        func: JsFunctionExpression | JsArrowFunctionExpression,
        admission: CallAdmission,
    ) -> None:
        if not admission.effects.summary_of(func).is_literal_replaceable:
            return
        known = admission.known_names(admission.effects.model.scope_of(node), func)
        if not is_evaluable(func, known, admission.effects.model):
            return
        interpreter = admission.interpreter_at(node)
        args = self._extract_constant_args(node.arguments, interpreter)
        if args is None:
            return
        self._evaluate_and_replace(node, func, args, interpreter, admission.effects, gate_unresolved=False)

    def _evaluate_and_replace(
        self,
        node: JsCallExpression,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        args: list,
        interpreter: JsInterpreter,
        effects: EffectModel,
        gate_unresolved: bool,
    ) -> bool:
        """
        Run *interpreter*, the one
        `refinery.lib.scripts.js.deobfuscation.admission.CallAdmission.interpreter_at` built for
        *node*, on *func* with *args* and, on success, replace *node* with the result. Returns True
        if the call site was resolved (either to a value or a substituted expression).
        """
        try:
            result = interpreter.execute(func, args)
        except IrreducibleExpression as irr:
            if gate_unresolved and self._is_unresolved_call(irr.node):
                return False
            if not effects.summary_of(func).is_expression_replaceable:
                # Splicing one body expression into the call site discards the rest of the body, so this
                # exit needs a stricter admission than the literal one that follows: the dropped statements
                # must carry no effect, and the spliced expression is not a fresh object the way a literal
                # is, so a mutation the body baked into its returned container cannot come along.
                return False
            if self._substitution_would_break(irr.node, func, node):
                return False
            replacement = self._substitute_params_in_clone(irr.node, func, args, self)
            if replacement is None:
                return False
            _replace_in_parent(node, replacement)
            self.mark_changed()
            return True
        except InterpreterError:
            return False
        if not replace_with_value(node, result):
            return False
        self.mark_changed()
        return True

    def _function_is_removable(self, model: SemanticModel, func: _FuncNode) -> bool:
        """
        Whether *func*'s definition can be deleted: it has no surviving static reference and cannot be
        reached by a runtime name lookup. A function named inside a `with` body or otherwise reflected
        (`model.reflection_can_reach`) must be kept even once every direct call has folded away — the
        `with`-body call still needs the binding. This mirrors the reflection gate the unused-code
        remover applies, so both transforms keep the same functions.

        A function a host reaches by name is likewise never removable, one the caller declared a
        host entrypoint or one the host calls as an event handler: the host calls it from outside
        the file, so no reference here can prove it dead. Both transforms consult the same
        predicate, or they would disagree about which functions survive.

        An exported binding is never removable for the same reason the other removal sweeps keep it:
        an importer reads its value across the module boundary once the module has run, so no
        reference here can prove it dead, and deleting the declarator would leave an `export` naming
        a binding the module no longer declares.
        """
        names = model.function_names(func)
        if names is None:
            return False
        exclude = self._function_exclude_node(func)
        for binding in names.bindings:
            if self._is_host_entrypoint(model, binding):
                return False
            if binding.exported:
                return False
            if binding_has_references(model, binding, exclude=exclude):
                return False
            if model.reflection_can_reach(binding):
                return False
        return True

    def _is_host_entrypoint(self, model: SemanticModel, binding: Binding) -> bool:
        """
        Whether *binding* names a function a host invokes by name, and is one a host could actually
        reach — a top-level declaration under the script execution model.
        """
        return model.reached_from_outside(binding)

    def _remove_resolved_definitions(self, script: JsScript, admission: CallAdmission) -> None:
        removed: set[int] = set()
        while True:
            model = model_cache(self, script).model
            before = len(removed)
            for func in admission.functions:
                func_id = id(func)
                if func_id in removed:
                    continue
                call_count = self._call_counts.get(func_id, 0)
                if call_count == 0:
                    continue
                resolved = self._resolved_counts.get(func_id, 0)
                failed = self._failed_counts.get(func_id, 0)
                if (resolved + failed) < call_count:
                    continue
                name = self._function_name(func)
                if name is None:
                    continue
                if self._function_is_removable(model, func):
                    self._remove_function(func)
                    removed.add(func_id)
                    self.mark_changed()
            for func in admission.functions:
                func_id = id(func)
                if func_id in removed:
                    continue
                if not admission.is_pure(func):
                    continue
                name = self._function_name(func)
                if name is None:
                    continue
                call_count = self._call_counts.get(func_id, 0)
                if call_count == 0:
                    continue
                if self._function_is_removable(model, func):
                    self._remove_function(func)
                    removed.add(func_id)
                    self.mark_changed()
            if len(removed) == before:
                break

    @staticmethod
    def _function_name(func: _FuncNode) -> str | None:
        if isinstance(func, JsFunctionDeclaration):
            return func.id.name if isinstance(func.id, JsIdentifier) else None
        declarator = func.parent
        if isinstance(declarator, JsVariableDeclarator) and isinstance(declarator.id, JsIdentifier):
            return declarator.id.name
        return None

    @staticmethod
    def _function_exclude_node(func: _FuncNode) -> Node:
        if isinstance(func, JsFunctionDeclaration):
            return func
        declarator = func.parent
        if isinstance(declarator, JsVariableDeclarator):
            return declarator
        return func

    def _remove_function(self, func: _FuncNode) -> None:
        if isinstance(func, JsFunctionDeclaration):
            _remove_from_parent(func)
        else:
            declarator = func.parent
            if isinstance(declarator, JsVariableDeclarator):
                remove_declarator(declarator)
            else:
                _remove_from_parent(func)

    def _extract_constant_args(
        self,
        arguments: list,
        interpreter: JsInterpreter,
    ) -> list[Value] | None:
        """
        The values of a call's *arguments*, or `None` when one of them is not known: a literal, or a
        name that holds a constant when the call runs. The name is read by the *interpreter* that
        runs the call, through
        `refinery.lib.scripts.js.deobfuscation.interpreter.JsInterpreter.constant_of`, so an
        argument naming a table is the same object a read of that name in the body answers, as it
        is in the program.
        """
        args: list[Value] = []
        for arg in arguments:
            known, value = extract_literal_value(arg)
            if not known and isinstance(arg, JsIdentifier):
                known, value = interpreter.constant_of(arg)
            if not known:
                return None
            args.append(value)
        return args

    def _is_unresolved_call(self, node: Node) -> bool:
        """
        Check whether the irreducible expression contains any function call. If so, the wrapper
        inliner or string-array resolver should handle it — the evaluator should not substitute
        parameters into a call that it couldn't fully evaluate.
        """
        for child in node.walk():
            if isinstance(child, JsCallExpression):
                return True
        return False

    def _substitution_would_break(self, node: Node, func: _FuncNode, call_site: Node) -> bool:
        """
        Whether splicing *node* — an irreducible sub-expression of *func*'s body — into *call_site* by
        parameter substitution would change behavior. Substitution replaces each parameter with its
        original argument value and discards the rest of the body, so it is unsafe when *node*:

        - writes a parameter, which would place the argument value at a write target (`delete p`
          becomes `delete <literal>`, `(p = 5)` becomes `(<literal> = 5)`);
        - reads a parameter that *func* reassigns — statically, or through a `with` body or direct
          `eval` — whose value at the irreducible point is no longer the original argument the
          substitution would supply;
        - references a name bound inside *func* but declared outside *node* — a body local (including a
          destructured or `catch` binding), a nested declaration, `arguments`, or the function
          expression's own name — which has no binding once the body is discarded, leaving a dangling
          reference;
        - references a name that resolves outside *func*, or to no binding at all, but which a
          same-named local in scope at *call_site* would recapture, so the spliced reference would bind
          to a different declaration there than it does in *func*.

        A binding *node* itself introduces travels with it and stays intact. Every reference is resolved
        through the model, so shadowing and destructuring are exact; a free name whose read crosses a
        `with` in *func* is treated as unsafe, since its runtime target cannot be matched at *call_site*.
        """
        script = self._script
        if script is None:
            return True
        model = model_cache(self, script).model
        call_scope = model.scope_of(call_site)
        if call_scope is None:
            return True
        param_bindings = {
            binding
            for param in func.params
            for ident in pattern_identifiers(param)
            if (binding := model.binding_of(ident)) is not None
        }
        for child in node.walk():
            if not isinstance(child, JsIdentifier) or not model.is_reference(child):
                continue
            binding = model.resolve(child)
            if binding is None:
                if model.read_has_dynamic_effect(child):
                    return True
                if model.lookup(child.name, call_scope) is not None:
                    return True
                continue
            if not binding.declared_within(func):
                if model.lookup(child.name, call_scope) is not binding:
                    return True
                continue
            if binding in param_bindings:
                if not model.binding_never_reassigned(binding):
                    return True
                continue
            if binding.declared_within(node):
                continue
            return True
        return False

    @staticmethod
    def _substitute_params_in_clone(
        node: Node,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        args: list[Value],
        transformer: Transformer,
    ) -> Node | None:
        """
        A clone of *node* with every parameter of *func* replaced by the argument the call handed it,
        or None where a parameter *node* reads has nothing to be replaced by.

        A parameter is left standing when the value handed to it has no faithful literal form, and a
        parameter that is not a plain name binds no single node to substitute at all. Either way it
        keeps its own name, and a name spliced into the call site reads whatever is bound to it there
        rather than the value the call made. Refusing leaves the call standing, which reduces less
        and states nothing false.
        """
        params: list[Node] = []
        arguments: list[Node] = []
        standing: set[str] = set()
        for index, param in enumerate(func.params):
            argument = None
            if isinstance(param, JsIdentifier):
                argument = value_to_node(args[index] if index < len(args) else None)
            if argument is None:
                standing.update(ident.name for ident in pattern_identifiers(param))
                continue
            params.append(param)
            arguments.append(argument)
        for child in node.walk():
            if isinstance(child, JsIdentifier) and child.name in standing:
                return None
        return substitute_params(node, params, arguments, transformer=transformer)

Classes

class JsFunctionEvaluator

Evaluate pure JavaScript functions called with constant arguments, replacing call sites with computed results. Handles named function calls and IIFEs.

Expand source code Browse git
class JsFunctionEvaluator(ScriptLevelTransformer):
    """
    Evaluate pure JavaScript functions called with constant arguments, replacing call sites with
    computed results. Handles named function calls and IIFEs.
    """

    def __init__(self):
        super().__init__()
        self._script: JsScript | None = None
        self._call_counts: dict[int, int] = {}
        self._resolved_counts: dict[int, int] = {}
        self._failed_counts: dict[int, int] = {}

    def _process_script(self, node: JsScript) -> None:
        """
        Decide every fold of one invocation against the model snapshot it opened with, holding the
        cache pinned through analysis and evaluation. The pin's obligation holds in one direction
        only, and every read obeys it: a fold replaces a call with its value or a substituted clone
        of a body expression, which only ever deletes — a callee reference, the effects the call
        carried, a tampering site it contained — so each held answer stays the stricter one and a
        later fold declines where a fresh model might have proceeded. The dominance a fold reads to
        know the callee's value is established before the call is a statement-order fact, which no
        expression replacement moves, and a spliced clone lands exactly where the call stood and
        carries only names `_substitution_would_break` has already bound identically at that spot,
        so no held fact is revealed more permissive by the splice. The held model has never
        resolved the names of such a clone, so a later fold running the code that holds it reads
        none of them off the model and declines instead.

        The removal of resolved definitions runs after the pin is released, against the model the
        cache rebuilds over the post-evaluation tree: whether anything still names a function is a
        structural fact the folds themselves change — each one deletes the references that kept
        the callee alive, and a spliced clone can name a binding the entry snapshot never saw —
        so this decision cannot be made against the held model without deleting a live function
        or holding a dead one back forever.
        """
        self._script = node
        self._call_counts.clear()
        self._resolved_counts.clear()
        self._failed_counts.clear()
        cache = model_cache(self, node)
        with cache.pinned():
            admission = CallAdmission(cache, node)
            self._evaluate_calls(node, admission)
        self._remove_resolved_definitions(node, admission)

    def _evaluate_calls(self, script: JsScript, admission: CallAdmission) -> None:
        for node in list(script.walk_in_order()):
            if not isinstance(node, JsCallExpression):
                continue
            if node.callee is None:
                continue
            callee = strip_parens(node.callee)
            if isinstance(callee, JsIdentifier):
                self._try_named_call(node, admission)
            elif isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
                self._try_iife(node, callee, admission)
            elif isinstance(callee, JsMemberExpression):
                self._try_method_chain(node, admission)

    def _try_method_chain(self, node: JsCallExpression, admission: CallAdmission) -> None:
        """
        Evaluate a method call whose value the interpreter can compute — the `[66, 79].map(f).join('')`
        decoder shape and everything it composes from. Unlike a call to a named function, there is no
        function node to run with arguments: the call *is* the expression, so it goes to
        `eval_expression` and the result replaces it.

        Walking outermost-first means the longest chain is attempted before any of its links, so a
        whole decoder collapses in one step and the inner links are gone before they are reached.
        Admission is the shared gate's decision alone, asked at the call
        (`refinery.lib.scripts.js.analysis.cache.ModelCache.trust_at`); this adds no question of its
        own, because every one it would ask — is the prototype intact, does a callback write outside
        itself, is an argument effectful — the gate already asks.
        """
        known, result = admission.method_chain_value(node)
        if known and replace_with_value(node, result):
            self.mark_changed()

    def _try_named_call(self, node: JsCallExpression, admission: CallAdmission) -> None:
        func = admission.callee(node)
        if func is None:
            return
        func_id = id(func)
        self._call_counts[func_id] = self._call_counts.get(func_id, 0) + 1
        interpreter = admission.interpreter_at(node)
        args = self._extract_constant_args(node.arguments, interpreter)
        if args is None:
            return
        success = self._evaluate_and_replace(
            node, func, args, interpreter, admission.effects, gate_unresolved=True
        )
        if success:
            self._resolved_counts[func_id] = self._resolved_counts.get(func_id, 0) + 1
        else:
            self._failed_counts[func_id] = self._failed_counts.get(func_id, 0) + 1

    def _try_iife(
        self,
        node: JsCallExpression,
        func: JsFunctionExpression | JsArrowFunctionExpression,
        admission: CallAdmission,
    ) -> None:
        if not admission.effects.summary_of(func).is_literal_replaceable:
            return
        known = admission.known_names(admission.effects.model.scope_of(node), func)
        if not is_evaluable(func, known, admission.effects.model):
            return
        interpreter = admission.interpreter_at(node)
        args = self._extract_constant_args(node.arguments, interpreter)
        if args is None:
            return
        self._evaluate_and_replace(node, func, args, interpreter, admission.effects, gate_unresolved=False)

    def _evaluate_and_replace(
        self,
        node: JsCallExpression,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        args: list,
        interpreter: JsInterpreter,
        effects: EffectModel,
        gate_unresolved: bool,
    ) -> bool:
        """
        Run *interpreter*, the one
        `refinery.lib.scripts.js.deobfuscation.admission.CallAdmission.interpreter_at` built for
        *node*, on *func* with *args* and, on success, replace *node* with the result. Returns True
        if the call site was resolved (either to a value or a substituted expression).
        """
        try:
            result = interpreter.execute(func, args)
        except IrreducibleExpression as irr:
            if gate_unresolved and self._is_unresolved_call(irr.node):
                return False
            if not effects.summary_of(func).is_expression_replaceable:
                # Splicing one body expression into the call site discards the rest of the body, so this
                # exit needs a stricter admission than the literal one that follows: the dropped statements
                # must carry no effect, and the spliced expression is not a fresh object the way a literal
                # is, so a mutation the body baked into its returned container cannot come along.
                return False
            if self._substitution_would_break(irr.node, func, node):
                return False
            replacement = self._substitute_params_in_clone(irr.node, func, args, self)
            if replacement is None:
                return False
            _replace_in_parent(node, replacement)
            self.mark_changed()
            return True
        except InterpreterError:
            return False
        if not replace_with_value(node, result):
            return False
        self.mark_changed()
        return True

    def _function_is_removable(self, model: SemanticModel, func: _FuncNode) -> bool:
        """
        Whether *func*'s definition can be deleted: it has no surviving static reference and cannot be
        reached by a runtime name lookup. A function named inside a `with` body or otherwise reflected
        (`model.reflection_can_reach`) must be kept even once every direct call has folded away — the
        `with`-body call still needs the binding. This mirrors the reflection gate the unused-code
        remover applies, so both transforms keep the same functions.

        A function a host reaches by name is likewise never removable, one the caller declared a
        host entrypoint or one the host calls as an event handler: the host calls it from outside
        the file, so no reference here can prove it dead. Both transforms consult the same
        predicate, or they would disagree about which functions survive.

        An exported binding is never removable for the same reason the other removal sweeps keep it:
        an importer reads its value across the module boundary once the module has run, so no
        reference here can prove it dead, and deleting the declarator would leave an `export` naming
        a binding the module no longer declares.
        """
        names = model.function_names(func)
        if names is None:
            return False
        exclude = self._function_exclude_node(func)
        for binding in names.bindings:
            if self._is_host_entrypoint(model, binding):
                return False
            if binding.exported:
                return False
            if binding_has_references(model, binding, exclude=exclude):
                return False
            if model.reflection_can_reach(binding):
                return False
        return True

    def _is_host_entrypoint(self, model: SemanticModel, binding: Binding) -> bool:
        """
        Whether *binding* names a function a host invokes by name, and is one a host could actually
        reach — a top-level declaration under the script execution model.
        """
        return model.reached_from_outside(binding)

    def _remove_resolved_definitions(self, script: JsScript, admission: CallAdmission) -> None:
        removed: set[int] = set()
        while True:
            model = model_cache(self, script).model
            before = len(removed)
            for func in admission.functions:
                func_id = id(func)
                if func_id in removed:
                    continue
                call_count = self._call_counts.get(func_id, 0)
                if call_count == 0:
                    continue
                resolved = self._resolved_counts.get(func_id, 0)
                failed = self._failed_counts.get(func_id, 0)
                if (resolved + failed) < call_count:
                    continue
                name = self._function_name(func)
                if name is None:
                    continue
                if self._function_is_removable(model, func):
                    self._remove_function(func)
                    removed.add(func_id)
                    self.mark_changed()
            for func in admission.functions:
                func_id = id(func)
                if func_id in removed:
                    continue
                if not admission.is_pure(func):
                    continue
                name = self._function_name(func)
                if name is None:
                    continue
                call_count = self._call_counts.get(func_id, 0)
                if call_count == 0:
                    continue
                if self._function_is_removable(model, func):
                    self._remove_function(func)
                    removed.add(func_id)
                    self.mark_changed()
            if len(removed) == before:
                break

    @staticmethod
    def _function_name(func: _FuncNode) -> str | None:
        if isinstance(func, JsFunctionDeclaration):
            return func.id.name if isinstance(func.id, JsIdentifier) else None
        declarator = func.parent
        if isinstance(declarator, JsVariableDeclarator) and isinstance(declarator.id, JsIdentifier):
            return declarator.id.name
        return None

    @staticmethod
    def _function_exclude_node(func: _FuncNode) -> Node:
        if isinstance(func, JsFunctionDeclaration):
            return func
        declarator = func.parent
        if isinstance(declarator, JsVariableDeclarator):
            return declarator
        return func

    def _remove_function(self, func: _FuncNode) -> None:
        if isinstance(func, JsFunctionDeclaration):
            _remove_from_parent(func)
        else:
            declarator = func.parent
            if isinstance(declarator, JsVariableDeclarator):
                remove_declarator(declarator)
            else:
                _remove_from_parent(func)

    def _extract_constant_args(
        self,
        arguments: list,
        interpreter: JsInterpreter,
    ) -> list[Value] | None:
        """
        The values of a call's *arguments*, or `None` when one of them is not known: a literal, or a
        name that holds a constant when the call runs. The name is read by the *interpreter* that
        runs the call, through
        `refinery.lib.scripts.js.deobfuscation.interpreter.JsInterpreter.constant_of`, so an
        argument naming a table is the same object a read of that name in the body answers, as it
        is in the program.
        """
        args: list[Value] = []
        for arg in arguments:
            known, value = extract_literal_value(arg)
            if not known and isinstance(arg, JsIdentifier):
                known, value = interpreter.constant_of(arg)
            if not known:
                return None
            args.append(value)
        return args

    def _is_unresolved_call(self, node: Node) -> bool:
        """
        Check whether the irreducible expression contains any function call. If so, the wrapper
        inliner or string-array resolver should handle it — the evaluator should not substitute
        parameters into a call that it couldn't fully evaluate.
        """
        for child in node.walk():
            if isinstance(child, JsCallExpression):
                return True
        return False

    def _substitution_would_break(self, node: Node, func: _FuncNode, call_site: Node) -> bool:
        """
        Whether splicing *node* — an irreducible sub-expression of *func*'s body — into *call_site* by
        parameter substitution would change behavior. Substitution replaces each parameter with its
        original argument value and discards the rest of the body, so it is unsafe when *node*:

        - writes a parameter, which would place the argument value at a write target (`delete p`
          becomes `delete <literal>`, `(p = 5)` becomes `(<literal> = 5)`);
        - reads a parameter that *func* reassigns — statically, or through a `with` body or direct
          `eval` — whose value at the irreducible point is no longer the original argument the
          substitution would supply;
        - references a name bound inside *func* but declared outside *node* — a body local (including a
          destructured or `catch` binding), a nested declaration, `arguments`, or the function
          expression's own name — which has no binding once the body is discarded, leaving a dangling
          reference;
        - references a name that resolves outside *func*, or to no binding at all, but which a
          same-named local in scope at *call_site* would recapture, so the spliced reference would bind
          to a different declaration there than it does in *func*.

        A binding *node* itself introduces travels with it and stays intact. Every reference is resolved
        through the model, so shadowing and destructuring are exact; a free name whose read crosses a
        `with` in *func* is treated as unsafe, since its runtime target cannot be matched at *call_site*.
        """
        script = self._script
        if script is None:
            return True
        model = model_cache(self, script).model
        call_scope = model.scope_of(call_site)
        if call_scope is None:
            return True
        param_bindings = {
            binding
            for param in func.params
            for ident in pattern_identifiers(param)
            if (binding := model.binding_of(ident)) is not None
        }
        for child in node.walk():
            if not isinstance(child, JsIdentifier) or not model.is_reference(child):
                continue
            binding = model.resolve(child)
            if binding is None:
                if model.read_has_dynamic_effect(child):
                    return True
                if model.lookup(child.name, call_scope) is not None:
                    return True
                continue
            if not binding.declared_within(func):
                if model.lookup(child.name, call_scope) is not binding:
                    return True
                continue
            if binding in param_bindings:
                if not model.binding_never_reassigned(binding):
                    return True
                continue
            if binding.declared_within(node):
                continue
            return True
        return False

    @staticmethod
    def _substitute_params_in_clone(
        node: Node,
        func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
        args: list[Value],
        transformer: Transformer,
    ) -> Node | None:
        """
        A clone of *node* with every parameter of *func* replaced by the argument the call handed it,
        or None where a parameter *node* reads has nothing to be replaced by.

        A parameter is left standing when the value handed to it has no faithful literal form, and a
        parameter that is not a plain name binds no single node to substitute at all. Either way it
        keeps its own name, and a name spliced into the call site reads whatever is bound to it there
        rather than the value the call made. Refusing leaves the call standing, which reduces less
        and states nothing false.
        """
        params: list[Node] = []
        arguments: list[Node] = []
        standing: set[str] = set()
        for index, param in enumerate(func.params):
            argument = None
            if isinstance(param, JsIdentifier):
                argument = value_to_node(args[index] if index < len(args) else None)
            if argument is None:
                standing.update(ident.name for ident in pattern_identifiers(param))
                continue
            params.append(param)
            arguments.append(argument)
        for child in node.walk():
            if isinstance(child, JsIdentifier) and child.name in standing:
                return None
        return substitute_params(node, params, arguments, transformer=transformer)

Ancestors

Inherited members