Module refinery.lib.scripts.js.deobfuscation.objectfold

Inline properties of locally-defined constant object literals.

When the obfuscator lifts string literals and operator wrappers into a local object, this transformer detects the pattern and replaces all member-access reads with the inlined property values. Function-valued properties that are trivial wrappers (single return statement whose body is an expression using only parameters) are inlined at the call site.

Expand source code Browse git
"""
Inline properties of locally-defined constant object literals.

When the obfuscator lifts string literals and operator wrappers into a local object, this
transformer detects the pattern and replaces all member-access reads with the inlined property
values. Function-valued properties that are trivial wrappers (single return statement whose body is
an expression using only parameters) are inlined at the call site.
"""
from __future__ import annotations

from typing import Iterator, NamedTuple

from refinery.lib.scripts import (
    Node,
    Statement,
    _clone_node,
    _replace_in_parent,
)
from refinery.lib.scripts.js.analysis.cache import ModelCache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    Scope,
    SemanticModel,
    is_direct_eval_call,
    is_use_position,
    timer_callee_name,
    walk_receiver_scope,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    Landing,
    PinnedTransformer,
    ScopeProcessingTransformer,
    a_host_reaches_the_binding,
    access_key,
    expression_a_call_answers,
    is_literal,
    make_undefined_expression,
    property_key,
    property_provably_absent,
    references_receiver_this,
    remove_declarator,
    spelled_for_the_callee_position,
    try_inline_trivial_function,
    value_landing,
    walk_scope,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsBinaryExpression,
    JsCallExpression,
    JsClassExpression,
    JsFunctionExpression,
    JsIdentifier,
    JsMemberExpression,
    JsNewExpression,
    JsObjectExpression,
    JsParenthesizedExpression,
    JsProperty,
    JsPropertyKind,
    JsRegExpLiteral,
    JsScript,
    JsTaggedTemplateExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    object_sets_prototype,
    static_string,
    strip_parens,
)


def _build_property_map(
    obj: JsObjectExpression,
) -> dict[str, Node] | None:
    """
    Build a map from string key to value node for every property in the object literal.
    Returns `None` if any property cannot be statically keyed (computed key, spread, etc.). Each value
    is stripped of enclosing parentheses so the fold guards inspect the expression itself: a
    parenthesized function value (`{ f: (function(){}) }`) must reach the identity guard as the function
    it wraps, or a bare `o.f` read would fold to a fresh clone and break `o.f === o.f`.
    """
    result: dict[str, Node] = {}
    for prop in obj.properties:
        if not isinstance(prop, JsProperty):
            return None
        if prop.kind is not JsPropertyKind.INIT:
            return None
        key = property_key(prop)
        value = strip_parens(prop.value)
        if key is None or value is None:
            return None
        result[key] = value
    return result


def _object_binds_this(prop_map: dict[str, Node]) -> bool:
    """
    Return whether any property value depends on `this` being supplied by the object, so that
    folding the object away (detaching the value from its receiver) would change its meaning.
    """
    return any(references_receiver_this(value) for value in prop_map.values())


def _function_observes_its_identity(func: JsFunctionExpression) -> bool:
    """
    Whether a call to *func* can tell one copy of the function from another: the body reads the
    function expression's own name, or reaches `arguments.callee` — through its own `arguments`
    binding, which an arrow inside the body shares and a nested regular function does not. Cloning
    such a function into each call site answers a different object at each, so `o.m() === o.m()`
    flips where the original compared one function against itself. A computed `arguments[k]` whose
    key is not statically known counts, since `k` may name `callee` at runtime.
    """
    name = func.id.name if func.id is not None else None
    if name is not None and any(
        isinstance(node, JsIdentifier)
        and node.name == name
        and node is not func.id
        and is_use_position(node)
        for node in func.walk()
    ):
        return True
    for node in walk_receiver_scope(func):
        if not isinstance(node, JsMemberExpression) or node.object is None:
            continue
        base = strip_parens(node.object)
        if not isinstance(base, JsIdentifier) or base.name != 'arguments':
            continue
        key = access_key(node)
        if key is None or key == 'callee':
            return True
    return False


def _binding_inside(binding: Binding, value: Node) -> bool:
    """
    Whether *binding* is declared inside *value* — its scope is introduced by *value* or a node nested
    within it — so that a clone of *value* carries the binding along and a reference to it stays bound
    to the same declaration wherever the clone lands.
    """
    node = binding.scope.node
    return node is value or node.is_descendant_of(value)


def _free_external_bindings(
    model: SemanticModel, value: Node
) -> Iterator[tuple[JsIdentifier, Binding | None]]:
    """
    Yield each free identifier in *value* that is in a use position, paired with the binding it
    resolves to (or `None` for an unresolved external name) — every reference a clone of *value*
    would re-resolve at its destination, excluding the ones bound inside *value* itself, which the
    clone carries along. The shared traversal behind `_value_is_stable` (does any such binding get
    reassigned) and `_resolves_consistently` (does any such name rebind at the fold site).
    """
    for node in value.walk():
        if not isinstance(node, JsIdentifier) or not model.is_reference(node):
            continue
        binding = model.resolve(node)
        if binding is not None and _binding_inside(binding, value):
            continue
        yield node, binding


def _resolves_consistently(
    model: SemanticModel,
    value: Node,
    dest: Scope | None,
    free: list[tuple[JsIdentifier, Binding | None]],
) -> bool:
    """
    Whether every free identifier in *value* resolves, from *dest* — the scope the value would be
    folded into — to the same binding it reads at the object literal. A value moved into a use site
    that binds one of its free names anew (a parameter, a block-scoped `let`, or the per-call
    `arguments` of a nested function) would silently rebind there and read a different value than the
    object captured. This is the spatial counterpart to the temporal `_value_is_stable`; an identifier
    bound inside *value* itself places no constraint, since the clone carries its binding with it.

    *free* is the `_free_external_bindings` list for *value*, which does not depend on *dest*, so a
    caller checking one value against several destinations walks the value once.
    """
    return all(binding is model.lookup(node.name, dest) for node, binding in free)


def _statement_holding(node: Node) -> Node | None:
    """
    The innermost statement *node* stands in, or `None` where it stands in none.
    """
    cursor: Node | None = node
    while cursor is not None and not isinstance(cursor, Statement):
        cursor = cursor.parent
    return cursor


def _copy_is_inert(value: Node, landing: Landing | None, *, handed: bool) -> bool:
    """
    Whether a copy of *value* that lands at *landing* leaves the held models as true of the program
    as they were where the value stood: in an object literal or, where it was *handed*, as an
    argument of the call an inlined function answers. The models read a value by where it lands
    (`refinery.lib.scripts.js.deobfuscation.helpers.value_landing`), and a copy brings no name they
    did not read where it stood:

    - Where they read nothing of the value, the copy shows them nothing.
    - A callee shows them something where the semantic model reads the call as running a string of
      code (`refinery.lib.scripts.js.analysis.model.timer_callee_name`).
    - A key names a property from then on.
    - An argument that was an argument of the inlined call is handed on as it was.
    - Anywhere else, a literal denotes no object and shows them nothing.

    Wherever it lands, a copy that completes a constant string shows them that string, which the
    caller asks of the code it wrote (`_joins_a_constant_string`).
    """
    if landing is None or landing is Landing.ELSEWHERE:
        return True
    if landing is Landing.CALLEE:
        return timer_callee_name(value) is None
    if landing is Landing.KEY:
        return False
    if landing is Landing.ARGUMENT and handed:
        return True
    return is_literal(value)


def _an_inlined_call_is_inert(func: JsFunctionExpression, call: JsCallExpression) -> bool:
    """
    Whether inlining *call* of *func* leaves the held models as true as they were
    (`_copy_is_inert`). Each argument lands where the expression the call answers reads its
    parameter: inside the expression, or where the call stands where the expression evaluates to it.
    A name the expression reads other than a parameter lands there as a copy of itself, and so does
    a constant string the expression evaluates to (`refinery.lib.scripts.js.model.static_string`),
    which names a property where the call stands in a key.
    """
    answered = expression_a_call_answers(func)
    assert answered is not None, 'a call is inlined only where it answers an expression'
    expression, names = answered
    passed = dict(zip(names, call.arguments))
    where_the_call_lands = value_landing(call)
    for node in expression.walk():
        landing = value_landing(node, expression)
        if landing is None:
            landing = where_the_call_lands
            if landing is Landing.KEY and static_string(node) is not None:
                return False
        if not isinstance(node, JsIdentifier) or not is_use_position(node):
            continue
        argument = passed.get(node.name)
        if argument is None:
            inert = _copy_is_inert(node, landing, handed=False)
        else:
            inert = _copy_is_inert(argument, landing, handed=True)
        if not inert:
            return False
    return True


def _joins_a_constant_string(node: Node) -> bool:
    """
    Whether *node*, code a fold just wrote, holds or stands in a concatenation the models read as
    the one string it spells (`refinery.lib.scripts.js.model.static_string`): as a key, and where
    they look for a name in every string. While a piece of it was a member access, they read it as
    no string at all.

    A concatenation the models read as one string is made of pieces they read as strings, so every
    concatenation inside it is read as one too. Where *node* holds such a concatenation, it holds
    one that joins no other, and where it stands in one, the nearest one around it is such a
    concatenation. Only those are asked, so a chain of copied pieces costs each piece the length of
    the chain rather than its square.
    """
    for joined in node.walk():
        if (
            isinstance(joined, JsBinaryExpression)
            and joined.operator == '+'
            and not _is_concatenation(joined.left)
            and not _is_concatenation(joined.right)
            and static_string(joined) is not None
        ):
            return True
    cursor = node
    while isinstance(parent := cursor.parent, JsParenthesizedExpression):
        cursor = parent
    return _is_concatenation(parent) and static_string(parent) is not None


def _is_concatenation(node: Node | None) -> bool:
    """
    Whether *node*, inside any parentheses, is a `+`, the operator a constant string is joined with.
    """
    node = strip_parens(node)
    return isinstance(node, JsBinaryExpression) and node.operator == '+'


def _call_of(member: JsMemberExpression) -> JsCallExpression | None:
    """
    The call *member* is the callee of, if any.
    """
    parent = member.parent
    return parent if isinstance(parent, JsCallExpression) and parent.callee is member else None


def _runs_code_in_its_scope(model: SemanticModel, value: Node) -> bool:
    """
    Whether *value* holds a direct `eval` the model reads as code that may name any binding in
    scope where it runs. A copy closed over the scope it is copied into would read and write the
    names of that scope instead of the ones around the literal, which no comparison of the names
    the value spells can see. The trusting model reads such code as naming nothing.
    """
    return not model.trust_eval and any(is_direct_eval_call(node) for node in value.walk())


class _Folded(NamedTuple):
    """
    What folding the references of one object did: whether it replaced any, and whether every one
    was folded away so the declaration can go.
    """
    changed: bool
    can_remove: bool


class _Answers:
    """
    Whether a value's names resolve at a destination as at the literal (`_resolves_consistently`),
    as one semantic *model* answers it, remembered per value and destination.
    """

    def __init__(self, model: SemanticModel):
        self.model = model
        self._free: dict[int, list[tuple[JsIdentifier, Binding | None]]] = {}
        self._consistent: dict[tuple[int, int], bool] = {}

    def resolve_consistently(self, value: Node, dest: Scope | None) -> bool:
        key = (id(value), id(dest))
        consistent = self._consistent.get(key)
        if consistent is None:
            free = self._free.get(id(value))
            if free is None:
                free = self._free[id(value)] = list(_free_external_bindings(self.model, value))
            consistent = _resolves_consistently(self.model, value, dest, free)
            self._consistent[key] = consistent
        return consistent


class JsObjectFold(PinnedTransformer, ScopeProcessingTransformer):
    """
    Inline properties of locally-defined constant objects. Processes at function-scope and
    script-scope boundaries because JavaScript `var` declarations are function-scoped.

    The pass holds the models for an invocation
    (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`). A fold replaces the
    references of one object with copies of its values and removes its declaration, and leaves the
    answers about the next object no more permissive than rebuilt ones:

    - Every edit goes through `_replace`, which records the statement the replaced code stood in,
      the names the replaced code spelled, and what the names the inserted code spells reach there:
      the binding the held model resolves each to, or the name where it resolves to none. Removing
      a declarator records the names its literal spelled. A fold adds a reference to a binding
      through inserted code that names it, or through a copy the last point renews after, and it
      detaches a reference only along with code it records. Before deciding an object whose
      binding, or one its literal reads, inserted code names, whose literal holds code the model
      does not know, or one of whose references an edit detached or stands in an edited statement,
      the pass renews.
    - Removing a declarator takes facts away, but for the references in it, which it detaches.
    - A copy that may show the models a fact they did not read where the value stood
      (`_copy_is_inert`) outdates them. The pass makes an object's copies first, renews before its
      inlined calls where a copy outdated the models, and renews again before it reads a fact that
      an inlined call may have changed: an inherited key, the values a removed declaration drops,
      the next object. The inlined calls of one object all read the facts as they stand after its
      copies, so a fact one call shows the models is not seen by a later call of the same object
      (`test_unfixed_defects.TestACallOfAnObjectSeesWhatAnEarlierCallOfItShowed`). Between a copy
      and the next renewal, the pass compares only where the names of a value resolve at the
      literal and at the reference. A copy changes what a name resolves to only at the global
      scope, which both reach alike.
    """

    def __init__(self):
        super().__init__()
        self._inserted_bindings: set[Binding] = set()
        self._inserted_names: set[str] = set()
        self._edited_statements: set[Node] = set()
        self._detached: set[Node] = set()
        self._outdated = False

    def visit_JsScript(self, node: JsScript):
        with self.holding_models(node):
            return super().visit_JsScript(node)

    def _forget_edits(self) -> None:
        self._inserted_bindings = set()
        self._inserted_names = set()
        self._edited_statements = set()
        self._detached = set()
        self._outdated = False

    def _facts(self) -> ModelCache:
        """
        The held models, renewed first where an edit since the last renewal outdated them: the
        models a decision that reads a fact about the program is made on.
        """
        if self._outdated:
            self.renew_models()
        return self.held_models()

    def _answers(self, answers: _Answers | None) -> _Answers:
        """
        The scope answers of the held semantic model: *answers* where they are its own. Every
        decision takes them at its reference, so that with the models released each reference is
        decided on the tree as it stands.
        """
        model = self.held_models().model
        if answers is None or answers.model is not model:
            answers = _Answers(model)
        return answers

    def _detach(self, removed: Node) -> None:
        """
        Record the names the code *removed*, which an edit took out of the tree, spelled: a held
        model still lists those among the references of the bindings they read.
        """
        self._detached.update(node for node in removed.walk() if isinstance(node, JsIdentifier))

    def _replace(self, model: SemanticModel, old: Node, new: Node) -> None:
        """
        Replace *old* with *new*, recording the statement *old* stood in, the names it spelled
        (`_detach`), and what each name *new* spells reaches there: the binding *model*, the one the
        reference was decided on, resolves it to from the scope of *old*, or the name itself where
        the model resolves it to none.
        """
        scope = model.scope_of(old)
        statement = _statement_holding(old)
        if not _replace_in_parent(old, new):
            return
        if statement is not None:
            self._edited_statements.add(statement)
        self._detach(old)
        for node in new.walk():
            if not isinstance(node, JsIdentifier) or not is_use_position(node):
                continue
            binding = model.lookup(node.name, scope)
            if binding is None:
                self._inserted_names.add(node.name)
            else:
                self._inserted_bindings.add(binding)

    def _names(self, binding: Binding | None, name: JsIdentifier) -> bool:
        """
        Whether code inserted since the last renewal names *binding*, the one *name* reaches, or a
        name spelled like *name* that the held model resolves to no binding.
        """
        return binding in self._inserted_bindings or name.name in self._inserted_names

    def _edited_around(
        self,
        model: SemanticModel,
        binding: Binding,
        name: JsIdentifier,
        init: JsObjectExpression,
        prop_map: dict[str, Node],
    ) -> bool:
        """
        Whether an edit since the last renewal touched what the held models say about the object
        *name* declares with *init*: inserted code names its binding or one its literal reads, its
        literal holds code the model does not know, or an edit detached one of its references or
        changed the statement one stands in, where the code around it may be code the models do not
        know, such as a function an earlier fold wrote where the callee of its call stood.
        """
        if not self._edited_statements and not self._detached:
            return False
        if self._names(binding, name):
            return True
        if not all(model.knows(node) for node in init.walk()):
            return True
        if any(
            self._names(read, identifier)
            for value in prop_map.values()
            for identifier, read in _free_external_bindings(model, value)
        ):
            return True
        return any(
            reference in self._detached or _statement_holding(reference) in self._edited_statements
            for reference in model.references(binding)
        )

    def _process_scope_body(self, scope: Node, body: list[Statement]) -> None:
        for declarator in list(self._find_candidates(body)):
            name = declarator.id
            init = declarator.init
            if not isinstance(name, JsIdentifier) or not isinstance(init, JsObjectExpression):
                continue
            prop_map = _build_property_map(init)
            if (
                prop_map is None
                or _object_binds_this(prop_map)
                or object_sets_prototype(init)
                or self._has_freshly_allocating_value(prop_map)
            ):
                continue
            cache = self._facts()
            model = cache.model
            binding = model.binding_of(name)
            if binding is None:
                continue
            if self._edited_around(model, binding, name, init, prop_map):
                self.renew_models()
                model = cache.model
                binding = model.binding_of(name)
            if binding is None or binding.writes or model.singular_value(binding) is not init:
                continue
            if a_host_reaches_the_binding(model, binding, self.options):
                continue
            if not cache.effects.is_side_effect_free(init, {name.name}):
                continue
            if not cache.effects.binding_is_immutable_container(binding, member_calls_mutate=False):
                continue
            if self._self_referential(model, binding, init):
                continue
            if any(not self._value_is_stable(model, value) for value in prop_map.values()):
                continue
            if any(
                self._eagerly_reads_mutable_container(model, cache.effects, value)
                for value in prop_map.values()
            ):
                continue
            if any(
                self._reads_lexical_in_dead_zone(model, cache.dominance, value)
                for value in prop_map.values()
            ):
                continue
            folded = self._inline_references(binding, prop_map)
            if folded.changed:
                if folded.can_remove:
                    remove_declarator(declarator)
                    self._detach(declarator)
                self.mark_changed()

    @staticmethod
    def _find_candidates(body: list[Statement]) -> Iterator[JsVariableDeclarator]:
        """
        Yield each variable declarator in *body* that initializes a variable to an object literal — the
        syntactic precondition for folding. Whether the literal is actually foldable (every key static,
        the name a single immutable binding) is decided per candidate by the caller against models
        renewed after any earlier fold that touched it, and its property map is read from the live
        initializer at that point, so an earlier fold into this initializer is reflected.
        """
        for stmt in body:
            if not isinstance(stmt, JsVariableDeclaration):
                continue
            for decl in stmt.declarations:
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.init, JsObjectExpression):
                    yield decl

    @staticmethod
    def _value_is_stable(model: SemanticModel, value: Node) -> bool:
        """
        Whether *value* evaluates to the same result wherever it is inlined — the precondition for
        moving a property value from the object literal to each access site. It holds when every
        binding the value reads as a free variable keeps its value: a property whose value reads a
        local that is reassigned (`{ p: x }` where `x` is later written) would, once inlined past
        the reassignment, read the new value instead of the one the object captured at the literal.
        A reassignment through a dynamic scope — a `with` body that names the binding, or a direct
        `eval` in its function — counts too, since it rebinds the name without leaving a static
        write; the model answers this over the binding's dynamic references. Identifiers bound
        inside *value* itself (a function's own parameters) and free references that resolve to no
        binding (external globals) place no constraint, so a string, a numeric literal, a `const`
        reference, or a self-contained function wrapper all remain foldable.
        """
        return all(
            binding is None or model.binding_never_reassigned(binding)
            for _, binding in _free_external_bindings(model, value)
        )

    @staticmethod
    def _eagerly_reads_mutable_container(
        model: SemanticModel, effects: EffectModel, value: Node,
    ) -> bool:
        """
        Whether *value* eagerly reads the contents of a mutable container — a binding holding an object
        or array whose contents are not stable after construction (`{ p: arr + '' }` followed by a later
        `arr.push(9)`). Folding clones the value into every access site, re-evaluating the read there, so
        a mutation made between the object's construction and a later read would be observed at the read
        but not at the literal. A bare-identifier value is exempt: folding it relocates a reference to the
        same binding, so the container's identity — and every mutation through it — stays equally visible
        at each site. A read inside a nested function is not eager: it runs only when the function is
        later called, resolving the container by reference then, so `walk_scope` does not enter one.
        Immutability is judged with the value's own read excluded, so coercing a primitive, or a container
        never mutated elsewhere — whose binding carries no other mutating reference — stays stable and
        still folds. This is the state-reading counterpart to `_value_is_stable`, which guards only against
        the binding being rebound, not against its container's contents being mutated in place.
        """
        if isinstance(value, JsIdentifier):
            return False
        for node in walk_scope(value):
            if not isinstance(node, JsIdentifier) or not model.is_reference(node):
                continue
            binding = model.resolve(node)
            if binding is None or _binding_inside(binding, value):
                continue
            if not effects.binding_is_immutable_container(binding, member_calls_mutate=True, exclude=value):
                return True
        return False

    @staticmethod
    def _reads_lexical_in_dead_zone(
        model: SemanticModel, dominance: DominanceModel, value: Node,
    ) -> bool:
        """
        Whether *value*, evaluated eagerly when the object literal is built, reads a `let`, `const`, or
        `class` binding not yet established there — a read in the binding's temporal dead zone, a
        `ReferenceError` at runtime. Folding clones the value into each access site, which may lie past
        the binding's declaration, so the read would move out of the dead zone and observe the declared
        value instead of throwing. A read inside a nested function is not eager — it runs at call time,
        where folding a called wrapper preserves its position — so `walk_scope` does not enter one. Only
        a lexical binding has a dead zone; a `var`, a parameter, or an external global places no
        constraint. This is the temporal-dead-zone counterpart to `_value_is_stable`, which guards only
        against the binding being rebound, not against the value reading it before its declaration runs.
        """
        for node in walk_scope(value):
            if not isinstance(node, JsIdentifier) or not model.is_reference(node):
                continue
            binding = model.resolve(node)
            if binding is None or _binding_inside(binding, value):
                continue
            if binding.is_lexical and not dominance.binding_established_before(binding, node):
                return True
        return False

    @staticmethod
    def _has_freshly_allocating_value(prop_map: dict[str, Node]) -> bool:
        """
        Whether any property value, when evaluated, may allocate a fresh object whose identity folding
        would duplicate. Folding clones the value into every access site, so a value that builds a new
        array or object — directly as a container literal, or by returning one from a call or `new`
        (only a side-effect-free, hence pure, one reaches this far) — would become a distinct object at
        each site: two `o.arr` reads that name one shared array become two arrays, diverging on identity
        (`o.arr === o.arr` flips from true to false), on a mutation made through one access (or an alias,
        argument, or method call that reaches it) and observed through another, and on element identity
        one level down. Deciding precisely which such folds are safe is the nested-container escape
        analysis the model does not yet provide, so such an object is left unfolded. A function or arrow
        literal value is judged per reference instead — folded only where it is immediately called, so
        its identity is never observed — and a primitive, a binding, a member read, or an operator over
        them duplicates without a fresh identity, so none of those constrains the fold.
        """
        return any(JsObjectFold._value_allocates(value) for value in prop_map.values())

    @staticmethod
    def _value_allocates(value: Node) -> bool:
        """
        Whether evaluating *value* may allocate a fresh object — a container literal it builds directly,
        a regular-expression or class literal (each evaluation mints a new object), or one a call,
        `new`, or tagged template in its evaluation may return. A function or arrow literal value is
        excluded, since its own identity is handled where it is folded; a nested function inside the
        value is not entered, as its body runs only when the function is later called, not when the
        value the object captured is evaluated.

        This is a *may*-allocate question and must not be merged with `EffectModel._fresh_kind`, which is a
        *must*-allocate one. The two answer about different rewrites: this one about identity duplication
        under cloning (`o.arr === o.arr` flipping if the fold mints two arrays), that one about whether a
        write through a base can be observed. They are not even monotone in one another — a bare call counts
        as allocating here and as `NOT_FRESH` there — so neither implies the other in either direction.
        """
        if isinstance(value, (JsFunctionExpression, JsArrowFunctionExpression)):
            return False
        return any(
            isinstance(node, (
                JsArrayExpression,
                JsObjectExpression,
                JsCallExpression,
                JsClassExpression,
                JsNewExpression,
                JsRegExpLiteral,
                JsTaggedTemplateExpression,
            ))
            for node in walk_scope(value)
        )

    @staticmethod
    def _self_referential(model: SemanticModel, binding: Binding, init: JsObjectExpression) -> bool:
        """
        Whether any reference to *binding* lies within its own initializer *init* — the object names
        itself in one of its property values (`var o = { f: function() { return o.x; } }`). Inlining
        such a value into a use site re-introduces a reference to the object there, so removing the
        declaration would leave it dangling; the caller skips folding the object rather than fold it
        into invalid code.
        """
        return any(ref.is_descendant_of(init) for ref in model.references(binding))

    def _inline_references(self, binding: Binding, prop_map: dict[str, Node]) -> _Folded:
        """
        Replace each `obj['key']` access through *binding* with the corresponding property value. For
        function-valued properties called as `obj['key'](args)`, inline the call. A key the property
        map does not hold is read off the prototype chain instead, so what it evaluates to is
        `property_provably_absent`'s to say and not this transform's: an inherited member is left
        intact because folding `o.toString` to `undefined` turns `o.toString()` into `undefined()`,
        and every key at all is left intact once the file has written to a prototype the read walks,
        because then `undefined` is not what the read answers. Iterating the binding's resolved
        references (not every textual occurrence of the name) keeps a shadowing inner binding of the
        same name untouched.

        Two per-reference conditions block a fold that would change meaning at the destination, leaving
        the access intact. A function-valued property is folded only where it is immediately called
        (the value's identity never escapes); a bare read of it is kept, since cloning it into two sites
        would make `o.f === o.f` two distinct functions. And a value is folded into a use site only when
        each of its free identifiers resolves to the same binding there as at the literal, so a value
        read inside a nested function that rebinds one of those names — a parameter, a block `let`, or
        that function's own `arguments` — is not silently recaptured.

        Returns `changed`, True when any replacement was made, and `can_remove`, True when every
        reference was folded away, so no use of the binding survives — a bare reference (an alias
        such as `var b = obj`), a retained inherited-member access, or a reference one of the two
        conditions above kept leaves *can_remove* False so the declaration is kept. A reference a
        dynamic scope resolves at runtime — a name inside a `with` body, held in the binding's
        dynamic references and never folded here — likewise keeps the declaration, so it is not
        removed out from under a use the fold left in place. A reference performed through a
        global-object alias (`globalThis.o`, a member node rather than an identifier) also keeps the
        declaration and is not folded: folding through it (`globalThis.o.x` to the property value)
        holds only under the script execution model, where a top-level `var` becomes a global
        property, not when the global is module-scoped. A reference an inlined call took out of the
        tree with its arguments keeps the declaration too, because the expression written where the
        call stood may hold a copy of it.

        A property no access reads is dropped with the declaration when it is removed, so a value
        whose read may throw a `ReferenceError` no completed write establishes keeps the
        declaration: building the object read that value, and dropping the declaration would mute
        the throw. A value whose operator converts an operand that may be an object keeps it too,
        because the conversion runs code the operand carries. A read the model proves established,
        and every value accessed at least once (relocated to its use, its read still performed
        there), places no such constraint. Relocating that read does move it past any statement
        between the literal and the use, so a throw it carries is reordered past those statements'
        effects — a separate, unpinned defect
        (`test_unfixed_defects.TestARelocatedMayThrowReadIsReorderedPastAnEffect`).

        A function value is copied into a call only where no direct `eval` in it reads names the
        copy would read from the scope of the call (`_runs_code_in_its_scope`), and it is written
        where the call's callee stood the way every substitution writes a value there
        (`refinery.lib.scripts.js.deobfuscation.helpers.spelled_for_the_callee_position`), so a bare
        `eval` copied there stays an indirect one.

        Copies are decided first and read only scopes, from the held model. The calls a function
        value may be inlined into come next, on models renewed once where a copy outdated them
        (`_copy_is_inert`), and inherited keys and the values a removed declaration drops last, on
        models renewed where any edit outdated them (`_facts`).
        """
        changed = False
        can_remove = not binding.dynamic_refs
        accessed_keys: set[str] = set()
        copies: list[tuple[JsMemberExpression, Node]] = []
        calls: list[tuple[JsIdentifier, JsMemberExpression, str, JsFunctionExpression]] = []
        inherited: list[tuple[JsIdentifier, JsMemberExpression, str]] = []
        model = self.held_models().model
        for ref in list(model.references(binding)):
            if ref in self._detached or not model.knows(ref):
                continue
            if not isinstance(ref, JsIdentifier):
                can_remove = False
                continue
            member = ref.parent
            if not isinstance(member, JsMemberExpression) or member.object is not ref:
                can_remove = False
                continue
            key = access_key(member)
            if key is None:
                can_remove = False
                continue
            value = prop_map.get(key)
            if value is None:
                inherited.append((ref, member, key))
            elif isinstance(value, JsFunctionExpression) and _call_of(member) is not None:
                calls.append((ref, member, key, value))
            else:
                accessed_keys.add(key)
                copies.append((member, value))
        answers: _Answers | None = None
        for member, value in copies:
            answers = self._answers(answers)
            inert = self._copy(answers, member, value)
            if inert is None:
                can_remove = False
                continue
            changed = True
            if not inert:
                self._outdated = True
        if calls:
            self._facts()
        for ref, member, key, value in calls:
            if ref in self._detached or not self.held_models().model.knows(ref):
                can_remove = False
                continue
            accessed_keys.add(key)
            answers = self._answers(answers)
            inert = self._inline_call(answers, member, value)
            if inert is None:
                can_remove = False
                continue
            changed = True
            if not inert:
                self._outdated = True
        for ref, member, key in inherited:
            if ref in self._detached or not self.held_models().model.knows(ref):
                can_remove = False
                continue
            facts = self._facts()
            if not property_provably_absent(facts.effects, dict, key):
                can_remove = False
                continue
            self._replace(facts.model, member, make_undefined_expression())
            changed = True
        dropped = [value for key, value in prop_map.items() if key not in accessed_keys]
        if can_remove and dropped:
            facts = self._facts()
            can_remove = all(
                facts.effects.is_side_effect_free(
                    value,
                    reads_may_throw=True,
                    read_established=facts.read_established,
                    coercions_may_write=True,
                )
                for value in dropped
            )
        return _Folded(changed, can_remove)

    def _inline_call(
        self,
        answers: _Answers,
        member: JsMemberExpression,
        value: JsFunctionExpression,
    ) -> bool | None:
        """
        Inline the call *member* is the callee of where *value* is a function the call answers an
        expression of, or else copy *value* where the callee stood (`_copy`), and tell whether the
        edit leaves the held models as true as they were (`_an_inlined_call_is_inert`); `None` where
        the reference has to stay as it is. Inlining reads facts about the program from the held
        models.
        """
        call = _call_of(member)
        assert call is not None
        replacement = try_inline_trivial_function(value, call.arguments, transformer=self)
        if replacement is not None:
            inert = _an_inlined_call_is_inert(value, call)
            replacement = spelled_for_the_callee_position(call, replacement)
            self._replace(answers.model, call, replacement)
            return inert and not _joins_a_constant_string(replacement)
        if _function_observes_its_identity(value):
            return None
        return self._copy(answers, member, value)

    def _copy(self, answers: _Answers, member: JsMemberExpression, value: Node) -> bool | None:
        """
        Copy *value* where *member* reads it, and tell whether the copy leaves the held models as
        true as they were (`_copy_is_inert`); `None` where the reference has to stay as it is. The
        decision reads only scopes, from the model of *answers*.
        """
        is_function = isinstance(value, (JsFunctionExpression, JsArrowFunctionExpression))
        if is_function and _call_of(member) is None:
            return None
        model = answers.model
        if _runs_code_in_its_scope(model, value):
            return None
        if not answers.resolve_consistently(value, model.scope_of(member)):
            return None
        inert = _copy_is_inert(value, value_landing(member), handed=False)
        copy = spelled_for_the_callee_position(member, _clone_node(value))
        self._replace(model, member, copy)
        return inert and not _joins_a_constant_string(copy)

Classes

class JsObjectFold

Inline properties of locally-defined constant objects. Processes at function-scope and script-scope boundaries because JavaScript var declarations are function-scoped.

The pass holds the models for an invocation (PinnedTransformer). A fold replaces the references of one object with copies of its values and removes its declaration, and leaves the answers about the next object no more permissive than rebuilt ones:

  • Every edit goes through _replace, which records the statement the replaced code stood in, the names the replaced code spelled, and what the names the inserted code spells reach there: the binding the held model resolves each to, or the name where it resolves to none. Removing a declarator records the names its literal spelled. A fold adds a reference to a binding through inserted code that names it, or through a copy the last point renews after, and it detaches a reference only along with code it records. Before deciding an object whose binding, or one its literal reads, inserted code names, whose literal holds code the model does not know, or one of whose references an edit detached or stands in an edited statement, the pass renews.
  • Removing a declarator takes facts away, but for the references in it, which it detaches.
  • A copy that may show the models a fact they did not read where the value stood (_copy_is_inert) outdates them. The pass makes an object's copies first, renews before its inlined calls where a copy outdated the models, and renews again before it reads a fact that an inlined call may have changed: an inherited key, the values a removed declaration drops, the next object. The inlined calls of one object all read the facts as they stand after its copies, so a fact one call shows the models is not seen by a later call of the same object (test_unfixed_defects.TestACallOfAnObjectSeesWhatAnEarlierCallOfItShowed). Between a copy and the next renewal, the pass compares only where the names of a value resolve at the literal and at the reference. A copy changes what a name resolves to only at the global scope, which both reach alike.
Expand source code Browse git
class JsObjectFold(PinnedTransformer, ScopeProcessingTransformer):
    """
    Inline properties of locally-defined constant objects. Processes at function-scope and
    script-scope boundaries because JavaScript `var` declarations are function-scoped.

    The pass holds the models for an invocation
    (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`). A fold replaces the
    references of one object with copies of its values and removes its declaration, and leaves the
    answers about the next object no more permissive than rebuilt ones:

    - Every edit goes through `_replace`, which records the statement the replaced code stood in,
      the names the replaced code spelled, and what the names the inserted code spells reach there:
      the binding the held model resolves each to, or the name where it resolves to none. Removing
      a declarator records the names its literal spelled. A fold adds a reference to a binding
      through inserted code that names it, or through a copy the last point renews after, and it
      detaches a reference only along with code it records. Before deciding an object whose
      binding, or one its literal reads, inserted code names, whose literal holds code the model
      does not know, or one of whose references an edit detached or stands in an edited statement,
      the pass renews.
    - Removing a declarator takes facts away, but for the references in it, which it detaches.
    - A copy that may show the models a fact they did not read where the value stood
      (`_copy_is_inert`) outdates them. The pass makes an object's copies first, renews before its
      inlined calls where a copy outdated the models, and renews again before it reads a fact that
      an inlined call may have changed: an inherited key, the values a removed declaration drops,
      the next object. The inlined calls of one object all read the facts as they stand after its
      copies, so a fact one call shows the models is not seen by a later call of the same object
      (`test_unfixed_defects.TestACallOfAnObjectSeesWhatAnEarlierCallOfItShowed`). Between a copy
      and the next renewal, the pass compares only where the names of a value resolve at the
      literal and at the reference. A copy changes what a name resolves to only at the global
      scope, which both reach alike.
    """

    def __init__(self):
        super().__init__()
        self._inserted_bindings: set[Binding] = set()
        self._inserted_names: set[str] = set()
        self._edited_statements: set[Node] = set()
        self._detached: set[Node] = set()
        self._outdated = False

    def visit_JsScript(self, node: JsScript):
        with self.holding_models(node):
            return super().visit_JsScript(node)

    def _forget_edits(self) -> None:
        self._inserted_bindings = set()
        self._inserted_names = set()
        self._edited_statements = set()
        self._detached = set()
        self._outdated = False

    def _facts(self) -> ModelCache:
        """
        The held models, renewed first where an edit since the last renewal outdated them: the
        models a decision that reads a fact about the program is made on.
        """
        if self._outdated:
            self.renew_models()
        return self.held_models()

    def _answers(self, answers: _Answers | None) -> _Answers:
        """
        The scope answers of the held semantic model: *answers* where they are its own. Every
        decision takes them at its reference, so that with the models released each reference is
        decided on the tree as it stands.
        """
        model = self.held_models().model
        if answers is None or answers.model is not model:
            answers = _Answers(model)
        return answers

    def _detach(self, removed: Node) -> None:
        """
        Record the names the code *removed*, which an edit took out of the tree, spelled: a held
        model still lists those among the references of the bindings they read.
        """
        self._detached.update(node for node in removed.walk() if isinstance(node, JsIdentifier))

    def _replace(self, model: SemanticModel, old: Node, new: Node) -> None:
        """
        Replace *old* with *new*, recording the statement *old* stood in, the names it spelled
        (`_detach`), and what each name *new* spells reaches there: the binding *model*, the one the
        reference was decided on, resolves it to from the scope of *old*, or the name itself where
        the model resolves it to none.
        """
        scope = model.scope_of(old)
        statement = _statement_holding(old)
        if not _replace_in_parent(old, new):
            return
        if statement is not None:
            self._edited_statements.add(statement)
        self._detach(old)
        for node in new.walk():
            if not isinstance(node, JsIdentifier) or not is_use_position(node):
                continue
            binding = model.lookup(node.name, scope)
            if binding is None:
                self._inserted_names.add(node.name)
            else:
                self._inserted_bindings.add(binding)

    def _names(self, binding: Binding | None, name: JsIdentifier) -> bool:
        """
        Whether code inserted since the last renewal names *binding*, the one *name* reaches, or a
        name spelled like *name* that the held model resolves to no binding.
        """
        return binding in self._inserted_bindings or name.name in self._inserted_names

    def _edited_around(
        self,
        model: SemanticModel,
        binding: Binding,
        name: JsIdentifier,
        init: JsObjectExpression,
        prop_map: dict[str, Node],
    ) -> bool:
        """
        Whether an edit since the last renewal touched what the held models say about the object
        *name* declares with *init*: inserted code names its binding or one its literal reads, its
        literal holds code the model does not know, or an edit detached one of its references or
        changed the statement one stands in, where the code around it may be code the models do not
        know, such as a function an earlier fold wrote where the callee of its call stood.
        """
        if not self._edited_statements and not self._detached:
            return False
        if self._names(binding, name):
            return True
        if not all(model.knows(node) for node in init.walk()):
            return True
        if any(
            self._names(read, identifier)
            for value in prop_map.values()
            for identifier, read in _free_external_bindings(model, value)
        ):
            return True
        return any(
            reference in self._detached or _statement_holding(reference) in self._edited_statements
            for reference in model.references(binding)
        )

    def _process_scope_body(self, scope: Node, body: list[Statement]) -> None:
        for declarator in list(self._find_candidates(body)):
            name = declarator.id
            init = declarator.init
            if not isinstance(name, JsIdentifier) or not isinstance(init, JsObjectExpression):
                continue
            prop_map = _build_property_map(init)
            if (
                prop_map is None
                or _object_binds_this(prop_map)
                or object_sets_prototype(init)
                or self._has_freshly_allocating_value(prop_map)
            ):
                continue
            cache = self._facts()
            model = cache.model
            binding = model.binding_of(name)
            if binding is None:
                continue
            if self._edited_around(model, binding, name, init, prop_map):
                self.renew_models()
                model = cache.model
                binding = model.binding_of(name)
            if binding is None or binding.writes or model.singular_value(binding) is not init:
                continue
            if a_host_reaches_the_binding(model, binding, self.options):
                continue
            if not cache.effects.is_side_effect_free(init, {name.name}):
                continue
            if not cache.effects.binding_is_immutable_container(binding, member_calls_mutate=False):
                continue
            if self._self_referential(model, binding, init):
                continue
            if any(not self._value_is_stable(model, value) for value in prop_map.values()):
                continue
            if any(
                self._eagerly_reads_mutable_container(model, cache.effects, value)
                for value in prop_map.values()
            ):
                continue
            if any(
                self._reads_lexical_in_dead_zone(model, cache.dominance, value)
                for value in prop_map.values()
            ):
                continue
            folded = self._inline_references(binding, prop_map)
            if folded.changed:
                if folded.can_remove:
                    remove_declarator(declarator)
                    self._detach(declarator)
                self.mark_changed()

    @staticmethod
    def _find_candidates(body: list[Statement]) -> Iterator[JsVariableDeclarator]:
        """
        Yield each variable declarator in *body* that initializes a variable to an object literal — the
        syntactic precondition for folding. Whether the literal is actually foldable (every key static,
        the name a single immutable binding) is decided per candidate by the caller against models
        renewed after any earlier fold that touched it, and its property map is read from the live
        initializer at that point, so an earlier fold into this initializer is reflected.
        """
        for stmt in body:
            if not isinstance(stmt, JsVariableDeclaration):
                continue
            for decl in stmt.declarations:
                if isinstance(decl, JsVariableDeclarator) and isinstance(decl.init, JsObjectExpression):
                    yield decl

    @staticmethod
    def _value_is_stable(model: SemanticModel, value: Node) -> bool:
        """
        Whether *value* evaluates to the same result wherever it is inlined — the precondition for
        moving a property value from the object literal to each access site. It holds when every
        binding the value reads as a free variable keeps its value: a property whose value reads a
        local that is reassigned (`{ p: x }` where `x` is later written) would, once inlined past
        the reassignment, read the new value instead of the one the object captured at the literal.
        A reassignment through a dynamic scope — a `with` body that names the binding, or a direct
        `eval` in its function — counts too, since it rebinds the name without leaving a static
        write; the model answers this over the binding's dynamic references. Identifiers bound
        inside *value* itself (a function's own parameters) and free references that resolve to no
        binding (external globals) place no constraint, so a string, a numeric literal, a `const`
        reference, or a self-contained function wrapper all remain foldable.
        """
        return all(
            binding is None or model.binding_never_reassigned(binding)
            for _, binding in _free_external_bindings(model, value)
        )

    @staticmethod
    def _eagerly_reads_mutable_container(
        model: SemanticModel, effects: EffectModel, value: Node,
    ) -> bool:
        """
        Whether *value* eagerly reads the contents of a mutable container — a binding holding an object
        or array whose contents are not stable after construction (`{ p: arr + '' }` followed by a later
        `arr.push(9)`). Folding clones the value into every access site, re-evaluating the read there, so
        a mutation made between the object's construction and a later read would be observed at the read
        but not at the literal. A bare-identifier value is exempt: folding it relocates a reference to the
        same binding, so the container's identity — and every mutation through it — stays equally visible
        at each site. A read inside a nested function is not eager: it runs only when the function is
        later called, resolving the container by reference then, so `walk_scope` does not enter one.
        Immutability is judged with the value's own read excluded, so coercing a primitive, or a container
        never mutated elsewhere — whose binding carries no other mutating reference — stays stable and
        still folds. This is the state-reading counterpart to `_value_is_stable`, which guards only against
        the binding being rebound, not against its container's contents being mutated in place.
        """
        if isinstance(value, JsIdentifier):
            return False
        for node in walk_scope(value):
            if not isinstance(node, JsIdentifier) or not model.is_reference(node):
                continue
            binding = model.resolve(node)
            if binding is None or _binding_inside(binding, value):
                continue
            if not effects.binding_is_immutable_container(binding, member_calls_mutate=True, exclude=value):
                return True
        return False

    @staticmethod
    def _reads_lexical_in_dead_zone(
        model: SemanticModel, dominance: DominanceModel, value: Node,
    ) -> bool:
        """
        Whether *value*, evaluated eagerly when the object literal is built, reads a `let`, `const`, or
        `class` binding not yet established there — a read in the binding's temporal dead zone, a
        `ReferenceError` at runtime. Folding clones the value into each access site, which may lie past
        the binding's declaration, so the read would move out of the dead zone and observe the declared
        value instead of throwing. A read inside a nested function is not eager — it runs at call time,
        where folding a called wrapper preserves its position — so `walk_scope` does not enter one. Only
        a lexical binding has a dead zone; a `var`, a parameter, or an external global places no
        constraint. This is the temporal-dead-zone counterpart to `_value_is_stable`, which guards only
        against the binding being rebound, not against the value reading it before its declaration runs.
        """
        for node in walk_scope(value):
            if not isinstance(node, JsIdentifier) or not model.is_reference(node):
                continue
            binding = model.resolve(node)
            if binding is None or _binding_inside(binding, value):
                continue
            if binding.is_lexical and not dominance.binding_established_before(binding, node):
                return True
        return False

    @staticmethod
    def _has_freshly_allocating_value(prop_map: dict[str, Node]) -> bool:
        """
        Whether any property value, when evaluated, may allocate a fresh object whose identity folding
        would duplicate. Folding clones the value into every access site, so a value that builds a new
        array or object — directly as a container literal, or by returning one from a call or `new`
        (only a side-effect-free, hence pure, one reaches this far) — would become a distinct object at
        each site: two `o.arr` reads that name one shared array become two arrays, diverging on identity
        (`o.arr === o.arr` flips from true to false), on a mutation made through one access (or an alias,
        argument, or method call that reaches it) and observed through another, and on element identity
        one level down. Deciding precisely which such folds are safe is the nested-container escape
        analysis the model does not yet provide, so such an object is left unfolded. A function or arrow
        literal value is judged per reference instead — folded only where it is immediately called, so
        its identity is never observed — and a primitive, a binding, a member read, or an operator over
        them duplicates without a fresh identity, so none of those constrains the fold.
        """
        return any(JsObjectFold._value_allocates(value) for value in prop_map.values())

    @staticmethod
    def _value_allocates(value: Node) -> bool:
        """
        Whether evaluating *value* may allocate a fresh object — a container literal it builds directly,
        a regular-expression or class literal (each evaluation mints a new object), or one a call,
        `new`, or tagged template in its evaluation may return. A function or arrow literal value is
        excluded, since its own identity is handled where it is folded; a nested function inside the
        value is not entered, as its body runs only when the function is later called, not when the
        value the object captured is evaluated.

        This is a *may*-allocate question and must not be merged with `EffectModel._fresh_kind`, which is a
        *must*-allocate one. The two answer about different rewrites: this one about identity duplication
        under cloning (`o.arr === o.arr` flipping if the fold mints two arrays), that one about whether a
        write through a base can be observed. They are not even monotone in one another — a bare call counts
        as allocating here and as `NOT_FRESH` there — so neither implies the other in either direction.
        """
        if isinstance(value, (JsFunctionExpression, JsArrowFunctionExpression)):
            return False
        return any(
            isinstance(node, (
                JsArrayExpression,
                JsObjectExpression,
                JsCallExpression,
                JsClassExpression,
                JsNewExpression,
                JsRegExpLiteral,
                JsTaggedTemplateExpression,
            ))
            for node in walk_scope(value)
        )

    @staticmethod
    def _self_referential(model: SemanticModel, binding: Binding, init: JsObjectExpression) -> bool:
        """
        Whether any reference to *binding* lies within its own initializer *init* — the object names
        itself in one of its property values (`var o = { f: function() { return o.x; } }`). Inlining
        such a value into a use site re-introduces a reference to the object there, so removing the
        declaration would leave it dangling; the caller skips folding the object rather than fold it
        into invalid code.
        """
        return any(ref.is_descendant_of(init) for ref in model.references(binding))

    def _inline_references(self, binding: Binding, prop_map: dict[str, Node]) -> _Folded:
        """
        Replace each `obj['key']` access through *binding* with the corresponding property value. For
        function-valued properties called as `obj['key'](args)`, inline the call. A key the property
        map does not hold is read off the prototype chain instead, so what it evaluates to is
        `property_provably_absent`'s to say and not this transform's: an inherited member is left
        intact because folding `o.toString` to `undefined` turns `o.toString()` into `undefined()`,
        and every key at all is left intact once the file has written to a prototype the read walks,
        because then `undefined` is not what the read answers. Iterating the binding's resolved
        references (not every textual occurrence of the name) keeps a shadowing inner binding of the
        same name untouched.

        Two per-reference conditions block a fold that would change meaning at the destination, leaving
        the access intact. A function-valued property is folded only where it is immediately called
        (the value's identity never escapes); a bare read of it is kept, since cloning it into two sites
        would make `o.f === o.f` two distinct functions. And a value is folded into a use site only when
        each of its free identifiers resolves to the same binding there as at the literal, so a value
        read inside a nested function that rebinds one of those names — a parameter, a block `let`, or
        that function's own `arguments` — is not silently recaptured.

        Returns `changed`, True when any replacement was made, and `can_remove`, True when every
        reference was folded away, so no use of the binding survives — a bare reference (an alias
        such as `var b = obj`), a retained inherited-member access, or a reference one of the two
        conditions above kept leaves *can_remove* False so the declaration is kept. A reference a
        dynamic scope resolves at runtime — a name inside a `with` body, held in the binding's
        dynamic references and never folded here — likewise keeps the declaration, so it is not
        removed out from under a use the fold left in place. A reference performed through a
        global-object alias (`globalThis.o`, a member node rather than an identifier) also keeps the
        declaration and is not folded: folding through it (`globalThis.o.x` to the property value)
        holds only under the script execution model, where a top-level `var` becomes a global
        property, not when the global is module-scoped. A reference an inlined call took out of the
        tree with its arguments keeps the declaration too, because the expression written where the
        call stood may hold a copy of it.

        A property no access reads is dropped with the declaration when it is removed, so a value
        whose read may throw a `ReferenceError` no completed write establishes keeps the
        declaration: building the object read that value, and dropping the declaration would mute
        the throw. A value whose operator converts an operand that may be an object keeps it too,
        because the conversion runs code the operand carries. A read the model proves established,
        and every value accessed at least once (relocated to its use, its read still performed
        there), places no such constraint. Relocating that read does move it past any statement
        between the literal and the use, so a throw it carries is reordered past those statements'
        effects — a separate, unpinned defect
        (`test_unfixed_defects.TestARelocatedMayThrowReadIsReorderedPastAnEffect`).

        A function value is copied into a call only where no direct `eval` in it reads names the
        copy would read from the scope of the call (`_runs_code_in_its_scope`), and it is written
        where the call's callee stood the way every substitution writes a value there
        (`refinery.lib.scripts.js.deobfuscation.helpers.spelled_for_the_callee_position`), so a bare
        `eval` copied there stays an indirect one.

        Copies are decided first and read only scopes, from the held model. The calls a function
        value may be inlined into come next, on models renewed once where a copy outdated them
        (`_copy_is_inert`), and inherited keys and the values a removed declaration drops last, on
        models renewed where any edit outdated them (`_facts`).
        """
        changed = False
        can_remove = not binding.dynamic_refs
        accessed_keys: set[str] = set()
        copies: list[tuple[JsMemberExpression, Node]] = []
        calls: list[tuple[JsIdentifier, JsMemberExpression, str, JsFunctionExpression]] = []
        inherited: list[tuple[JsIdentifier, JsMemberExpression, str]] = []
        model = self.held_models().model
        for ref in list(model.references(binding)):
            if ref in self._detached or not model.knows(ref):
                continue
            if not isinstance(ref, JsIdentifier):
                can_remove = False
                continue
            member = ref.parent
            if not isinstance(member, JsMemberExpression) or member.object is not ref:
                can_remove = False
                continue
            key = access_key(member)
            if key is None:
                can_remove = False
                continue
            value = prop_map.get(key)
            if value is None:
                inherited.append((ref, member, key))
            elif isinstance(value, JsFunctionExpression) and _call_of(member) is not None:
                calls.append((ref, member, key, value))
            else:
                accessed_keys.add(key)
                copies.append((member, value))
        answers: _Answers | None = None
        for member, value in copies:
            answers = self._answers(answers)
            inert = self._copy(answers, member, value)
            if inert is None:
                can_remove = False
                continue
            changed = True
            if not inert:
                self._outdated = True
        if calls:
            self._facts()
        for ref, member, key, value in calls:
            if ref in self._detached or not self.held_models().model.knows(ref):
                can_remove = False
                continue
            accessed_keys.add(key)
            answers = self._answers(answers)
            inert = self._inline_call(answers, member, value)
            if inert is None:
                can_remove = False
                continue
            changed = True
            if not inert:
                self._outdated = True
        for ref, member, key in inherited:
            if ref in self._detached or not self.held_models().model.knows(ref):
                can_remove = False
                continue
            facts = self._facts()
            if not property_provably_absent(facts.effects, dict, key):
                can_remove = False
                continue
            self._replace(facts.model, member, make_undefined_expression())
            changed = True
        dropped = [value for key, value in prop_map.items() if key not in accessed_keys]
        if can_remove and dropped:
            facts = self._facts()
            can_remove = all(
                facts.effects.is_side_effect_free(
                    value,
                    reads_may_throw=True,
                    read_established=facts.read_established,
                    coercions_may_write=True,
                )
                for value in dropped
            )
        return _Folded(changed, can_remove)

    def _inline_call(
        self,
        answers: _Answers,
        member: JsMemberExpression,
        value: JsFunctionExpression,
    ) -> bool | None:
        """
        Inline the call *member* is the callee of where *value* is a function the call answers an
        expression of, or else copy *value* where the callee stood (`_copy`), and tell whether the
        edit leaves the held models as true as they were (`_an_inlined_call_is_inert`); `None` where
        the reference has to stay as it is. Inlining reads facts about the program from the held
        models.
        """
        call = _call_of(member)
        assert call is not None
        replacement = try_inline_trivial_function(value, call.arguments, transformer=self)
        if replacement is not None:
            inert = _an_inlined_call_is_inert(value, call)
            replacement = spelled_for_the_callee_position(call, replacement)
            self._replace(answers.model, call, replacement)
            return inert and not _joins_a_constant_string(replacement)
        if _function_observes_its_identity(value):
            return None
        return self._copy(answers, member, value)

    def _copy(self, answers: _Answers, member: JsMemberExpression, value: Node) -> bool | None:
        """
        Copy *value* where *member* reads it, and tell whether the copy leaves the held models as
        true as they were (`_copy_is_inert`); `None` where the reference has to stay as it is. The
        decision reads only scopes, from the model of *answers*.
        """
        is_function = isinstance(value, (JsFunctionExpression, JsArrowFunctionExpression))
        if is_function and _call_of(member) is None:
            return None
        model = answers.model
        if _runs_code_in_its_scope(model, value):
            return None
        if not answers.resolve_consistently(value, model.scope_of(member)):
            return None
        inert = _copy_is_inert(value, value_landing(member), handed=False)
        copy = spelled_for_the_callee_position(member, _clone_node(value))
        self._replace(model, member, copy)
        return inert and not _joins_a_constant_string(copy)

Ancestors

Methods

def visit_JsScript(self, node)
Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    with self.holding_models(node):
        return super().visit_JsScript(node)

Inherited members