Module refinery.lib.scripts.js.deobfuscation.cff.statemachine
Recover original code from generator-based state-machine CFF dispatchers.
Handles the pattern where a function body is replaced with a generator function containing a
while/switch state machine driven by multiple state variables whose sum is the switch
discriminant. Each case updates the state via relative += assignments.
Expand source code Browse git
"""
Recover original code from generator-based state-machine CFF dispatchers.
Handles the pattern where a function body is replaced with a generator function containing a
while/switch state machine driven by multiple state variables whose sum is the switch
discriminant. Each case updates the state via relative `+=` assignments.
"""
from __future__ import annotations
import enum
import math
from collections import deque
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Iterator, NamedTuple, Sequence
from refinery.lib.scripts import (
Expression,
Node,
Statement,
_clone_node,
_remove_from_parent,
_replace_in_parent,
set_child,
set_child_list,
)
from refinery.lib.scripts.js.analysis.intrinsics import denoting_leaves
from refinery.lib.scripts.js.analysis.model import (
annex_b_var_home,
is_member_write_target,
is_simple_assignment_target,
is_use_position,
lexically_declared_names,
references_own_arguments,
walk_receiver_scope,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
BodyProcessingTransformer,
PinnedTransformer,
access_key,
declares_a_watched_name,
eval_binary_op,
fresh_name,
is_literal,
is_reference,
is_valid_identifier,
make_numeric_literal,
make_undefined_expression,
property_absent_from_written_chain,
property_key,
sanitize_inlined_body,
substitute_use_position,
walk_scope,
)
from refinery.lib.scripts.js.model import (
FUNCTION_NODES,
LOOP_NODES,
JsArrayExpression,
JsArrayPattern,
JsAssignmentExpression,
JsAssignmentPattern,
JsAwaitExpression,
JsBinaryExpression,
JsBlockStatement,
JsBooleanLiteral,
JsBreakStatement,
JsCallExpression,
JsCatchClause,
JsClassDeclaration,
JsClassExpression,
JsContinueStatement,
JsExpressionStatement,
JsForInStatement,
JsForOfStatement,
JsForStatement,
JsFunctionDeclaration,
JsFunctionExpression,
JsFunctionNode,
JsIdentifier,
JsIfStatement,
JsLabeledStatement,
JsLogicalExpression,
JsMemberExpression,
JsMetaProperty,
JsNewExpression,
JsNumericLiteral,
JsObjectExpression,
JsObjectPattern,
JsProperty,
JsPropertyKind,
JsRestElement,
JsReturnStatement,
JsScript,
JsSequenceExpression,
JsSpreadElement,
JsStringLiteral,
JsSwitchCase,
JsSwitchStatement,
JsTaggedTemplateExpression,
JsThisExpression,
JsThrowStatement,
JsUnaryExpression,
JsUpdateExpression,
JsVariableDeclaration,
JsVariableDeclarator,
JsVarKind,
JsWhileStatement,
JsWithStatement,
JsYieldExpression,
is_async_function,
is_generator_function,
object_member_access_runs_accessor,
static_string,
strip_parens,
)
from refinery.lib.scripts.js.numbers import is_negative_zero
from refinery.lib.scripts.js.strict import (
directive_prologue,
keeping_directives,
strict_mode_at,
)
class _Undefined(enum.Enum):
"""
The value `undefined`, which a case test reads from a routing slot no store has filled yet.
"""
VALUE = 'undefined'
_UNDEFINED = _Undefined.VALUE
if TYPE_CHECKING:
from refinery.lib.scripts.js.analysis.intrinsics import IntrinsicWrites
from refinery.lib.scripts.js.analysis.model import Binding, SemanticModel
_StateEnv = dict[str, float]
_SlotPath = tuple[str, ...]
_Value = float | bool | _Undefined
_Slots = dict[_SlotPath, _Value]
_MAX_BLOCKS = 2000
"""
The most blocks the recovery reads for one run of a machine; it leaves a larger machine as it is.
"""
class _CallSiteInfo(NamedTuple):
initial_state: list[int | float]
did_return_var: str | None
result_var: str | None
scaffolding_end: int
returns_value: bool
guarded: bool
class _WrapperFunctionInfo(NamedTuple):
initial_state: list[int | float]
rest_param_name: str | None
scope_arg: Expression
@dataclass
class _SMRawAssignment:
"""
A single unevaluated state variable assignment: `name op= rhs`, and the node that makes it.
"""
name: str
operator: str
rhs: Expression
node: JsAssignmentExpression
@dataclass
class _SMLinearTransition:
"""
Unconditional state transition: a sequence of `+=` or `=` assignments to state variables.
"""
assignments: list[_SMRawAssignment]
@dataclass
class _SMConditionalTransition:
"""
Conditional state transition: an if/else where each branch sets different state values.
"""
condition: Expression
true_assignments: list[_SMRawAssignment]
false_assignments: list[_SMRawAssignment]
true_prefix: list[Statement] = field(default_factory=list)
false_prefix: list[Statement] = field(default_factory=list)
@dataclass
class _SMExitTransition:
"""
The state machine reaches the end state after this block.
"""
pass
if TYPE_CHECKING:
_SMTransition = _SMLinearTransition | _SMConditionalTransition | _SMExitTransition
@dataclass
class _SMBlock:
"""
A single state in the machine: payload statements plus a transition, and the constants the
payload stores in routing slots before anything else runs (`_routing_stores`).
"""
payload: list[Statement]
transition: _SMTransition
routing_stores: list[tuple[_SlotPath, _Value]] = field(default_factory=list)
@dataclass
class _GeneratorCFFMatch:
generator_name: str
state_var_names: list[str]
initial_state: list[int | float]
end_state: int | float
loop_sum: Expression
switch_stmt: JsSwitchStatement
switch_label: str | None
scope_param_name: str | None
arg_var_name: str | None
did_return_var: str | None
result_var: str | None
gen_decl_index: int
scaffolding_end: int
with_redirect_var: str | None = None
routed: bool = False
returns_value: bool = False
guarded: bool = False
scope_default_props: list[str] = field(default_factory=list)
scope_default_inits: dict[str, Expression] = field(default_factory=dict)
scope_prop_names: set[str] = field(default_factory=set)
namespaces: set[str] = field(default_factory=set)
namespace_homes: dict[str, tuple[str, ...]] = field(default_factory=dict)
@property
def qualifies_namespaces(self) -> bool:
"""
Whether this generator uses the with-redirect namespace-qualification path: a single scope
default namespace together with a redirect variable. When false, bare identifiers inside the
`with` are left unqualified.
"""
return self.with_redirect_var is not None and len(self.scope_default_props) == 1
@dataclass
class _Env:
"""
What a run of the machine reads when it picks the next case: the value of every state variable,
and that of each routing slot (`_routing_paths`) whose value is known.
"""
states: _StateEnv
slots: _Slots = field(default_factory=dict)
def same_as(self, other: _Env) -> bool:
"""
Whether *other* holds the same values under the same names (`_same_value`).
"""
return (
self.states.keys() == other.states.keys()
and self.slots.keys() == other.slots.keys()
and all(_same_value(value, other.states[name]) for name, value in self.states.items())
and all(_same_value(value, other.slots[path]) for path, value in self.slots.items())
)
def _to_number(value: _Value) -> float:
if isinstance(value, _Undefined):
return math.nan
return float(value)
def _is_truthy(value: _Value) -> bool:
if isinstance(value, _Undefined):
return False
if isinstance(value, bool):
return value
return not (value == 0 or math.isnan(value))
def _strictly_equal(a: _Value, b: _Value) -> bool:
"""
JavaScript's `===` on the values a case test can make: a boolean is never a number.
"""
if isinstance(a, (bool, _Undefined)) or isinstance(b, (bool, _Undefined)):
return a is b
return a == b
def _same_value(a: _Value, b: _Value) -> bool:
"""
JavaScript's SameValue on the values the machine reads: `===`, except that it tells `-0` from
`0`, which a block reading the value would print or divide by differently.
"""
if isinstance(a, float) and isinstance(b, float) and is_negative_zero(a) != is_negative_zero(b):
return False
return _strictly_equal(a, b)
def _finite_number(value: _Value | None) -> float | None:
"""
The value where it is a finite number, the only value a state variable holds, or `None`.
"""
if isinstance(value, (bool, _Undefined)) or value is None or not math.isfinite(value):
return None
return value
def _loosely_equal(a: _Value, b: _Value) -> bool:
"""
JavaScript's `==` on the values a case test can make: `undefined` equals only itself here, and a
boolean compares as the number it converts to.
"""
if isinstance(a, _Undefined) or isinstance(b, _Undefined):
return a is b
return _to_number(a) == _to_number(b)
def _eval_expr(node: Expression, env: _Env) -> _Value | None:
"""
Evaluate an expression of numbers, state variables and routing slots the way JavaScript does,
or give `None` where it reads anything else or applies an operator this does not know. A
comparison makes a boolean, which stays one, and arithmetic on `undefined` makes `NaN`.
"""
if isinstance(node, JsNumericLiteral):
return node.value
if isinstance(node, JsIdentifier):
return env.states.get(node.name)
if isinstance(node, JsMemberExpression):
path = _slot_path(node)
return None if path is None else env.slots.get(path)
if isinstance(node, JsUnaryExpression) and node.prefix and node.operand is not None:
if node.operator in ('-', '+'):
operand = _eval_expr(node.operand, env)
if operand is None:
return None
number = _to_number(operand)
return -number if node.operator == '-' else number
if isinstance(node, JsLogicalExpression) and node.left is not None and node.right is not None:
if node.operator in ('&&', '||'):
lhs = _eval_expr(node.left, env)
if lhs is None:
return None
if _is_truthy(lhs) == (node.operator == '||'):
return lhs
return _eval_expr(node.right, env)
if isinstance(node, JsBinaryExpression) and node.left is not None and node.right is not None:
lhs = _eval_expr(node.left, env)
rhs = _eval_expr(node.right, env)
if lhs is None or rhs is None:
return None
if node.operator in ('===', '!=='):
return _strictly_equal(lhs, rhs) == (node.operator == '===')
if node.operator in ('==', '!='):
return _loosely_equal(lhs, rhs) == (node.operator == '==')
return eval_binary_op(node.operator, _to_number(lhs), _to_number(rhs))
return None
def _eval_number(node: Expression) -> float | None:
"""
The finite number *node* spells without reading anything, or `None`.
"""
return _finite_number(_eval_expr(node, _Env({})))
def _slot_path(node: Expression) -> _SlotPath | None:
"""
The names along a chain of property accesses under static keys, starting with the variable it
starts from: `scope.M["p"]` gives `('scope', 'M', 'p')`. Any other expression gives `None`.
"""
parts: list[str] = []
cursor: Expression | None = node
while isinstance(cursor, JsMemberExpression):
if (key := access_key(cursor)) is None:
return None
parts.append(key)
cursor = cursor.object
if not parts or not isinstance(cursor, JsIdentifier):
return None
parts.append(cursor.name)
parts.reverse()
return tuple(parts)
def _is_discriminant_sum(node: Expression, var_names: list[str]) -> bool:
"""
Check whether an expression is the sum of the given state variable identifiers.
"""
collected: list[str] = []
return _collect_sum_idents(node, collected) and sorted(collected) == sorted(var_names)
def _collect_sum_idents(node: Expression, out: list[str]) -> bool:
if isinstance(node, JsIdentifier):
out.append(node.name)
return True
if isinstance(node, JsBinaryExpression) and node.operator == '+':
if node.left is not None and node.right is not None:
return _collect_sum_idents(node.left, out) and _collect_sum_idents(node.right, out)
return False
def _extract_with_redirect_var(
with_obj: Expression | None,
scope_param_name: str | None,
) -> str | None:
"""
Parse the `with(scope.W || scope)` pattern to extract the redirect property name `W`.
"""
if scope_param_name is None or with_obj is None:
return None
if not isinstance(with_obj, JsLogicalExpression) or with_obj.operator != '||':
return None
lhs = with_obj.left
rhs = with_obj.right
if not isinstance(rhs, JsIdentifier) or rhs.name != scope_param_name:
return None
if not isinstance(lhs, JsMemberExpression):
return None
if not isinstance(lhs.object, JsIdentifier) or lhs.object.name != scope_param_name:
return None
if lhs.computed:
if not isinstance(lhs.property, JsStringLiteral):
return None
return lhs.property.value
if not isinstance(lhs.property, JsIdentifier):
return None
return lhs.property.name
class _ScopeDefaults(NamedTuple):
prop_names: list[str]
initializers: dict[str, Expression]
def _scope_object_properties(scope: JsObjectExpression) -> dict[str, Expression] | None:
"""
The properties a scope object literal creates, by key, or `None` where one of them is not a
plain data property under a key known before it runs: a spread, a method or accessor, a computed
key that is not a constant string, or a non-computed `__proto__`, which sets the prototype of
the object rather than creating a property. `{["K"]: {}}` creates `K` as surely as `{K: {}}`.
A key written twice gives `None` as well: the literal evaluates both values and keeps the
second, and only the kept one is read here.
"""
properties: dict[str, Expression] = {}
for prop in scope.properties:
if not isinstance(prop, JsProperty) or prop.method or prop.kind is not JsPropertyKind.INIT:
return None
if prop.computed:
key = static_string(prop.key)
elif (key := property_key(prop)) == '__proto__' and not prop.shorthand:
return None
if key is None or prop.value is None or key in properties:
return None
properties[key] = prop.value
return properties
def _is_inert(node: Node | None) -> bool:
"""
Whether evaluating *node* does nothing but make its value, and the value holds nothing but data:
a literal, a function, or an object or array literal of such values. The recovery declares a
namespace only where the recovered code still refers to it, which drops the evaluation of an
initializer nothing refers to, and it reads a store to a member of a namespace as a store of
data: a getter or setter would run code on a later access instead, and a `__proto__` key gives
the object a prototype rather than a property.
"""
if isinstance(node, JsObjectExpression):
return not object_member_access_runs_accessor(node) and all(
isinstance(prop, JsProperty) and not prop.computed and _is_inert(prop.value)
for prop in node.properties
)
if isinstance(node, JsArrayExpression):
return all(element is None or _is_inert(element) for element in node.elements)
return isinstance(node, FUNCTION_NODES) or node is not None and is_literal(node)
def _extract_scope_default_props(
params: list, scope_param_name: str | None,
) -> _ScopeDefaults | None:
"""
Extract namespace property names and their initializer expressions from the scope parameter's
default value. For the pattern `scope = { MpAqdCF: {} }` this returns a tuple of the form
(['MpAqdCF'], {'MpAqdCF': <JsObjectExpression>})
A default that is not an object literal whose properties `_scope_object_properties` reads gives
`None`: the recovery cannot know which names the scope object carries. So does one holding a
value whose evaluation does more than make it (`_is_inert`).
"""
if scope_param_name is None:
return _ScopeDefaults([], {})
for p in params:
if not isinstance(p, JsAssignmentPattern):
continue
if not isinstance(p.left, JsIdentifier) or p.left.name != scope_param_name:
continue
if not isinstance(p.right, JsObjectExpression):
return None
properties = _scope_object_properties(p.right)
if properties is None or not all(map(_is_inert, properties.values())):
return None
return _ScopeDefaults(list(properties), properties)
return _ScopeDefaults([], {})
def _namespace_member_home(
node: Expression | None,
scope_param_name: str | None,
ns_names: set[str],
) -> tuple[str, str] | None:
"""
If *node* is an assignment target that defines a namespace-local slot — either `NS.prop` on a
bare namespace identifier or `scope.NS.prop` on the scope parameter — return the pair
`(member_name, home_namespace)`. Any other target yields `None`.
"""
if not isinstance(node, JsMemberExpression):
return None
obj = node.object
if isinstance(obj, JsIdentifier):
if obj.name in ns_names:
member = access_key(node)
if member is not None:
return (member, obj.name)
return None
if (
scope_param_name is not None
and isinstance(obj, JsMemberExpression)
and isinstance(obj.object, JsIdentifier)
and obj.object.name == scope_param_name
):
home = access_key(obj)
if home is not None and home in ns_names:
member = access_key(node)
if member is not None:
return (member, home)
return None
def _collect_assignment_targets(
target: Expression | None,
scope_param_name: str | None,
ns_names: set[str],
out: dict[str, set[str]],
) -> None:
"""
Record the home namespace of every namespace-local slot written by *target*, mapping each member
name to the set of namespaces it is written under. Recurses through array and object
destructuring patterns so that `[NS.a, NS.b] = …` and `({p: NS.c} = …)` are covered, including
the object-literal spelling the parser leaves as an expression for a nested property value, so
that a namespace member destructured at any depth still contributes its home.
"""
home = _namespace_member_home(target, scope_param_name, ns_names)
if home is not None:
member, namespace = home
out.setdefault(member, set()).add(namespace)
return
if isinstance(target, (JsArrayPattern, JsArrayExpression)):
for elem in target.elements:
if elem is not None:
_collect_assignment_targets(elem, scope_param_name, ns_names, out)
elif isinstance(target, (JsObjectPattern, JsObjectExpression)):
for prop in target.properties:
if isinstance(prop, JsProperty):
_collect_assignment_targets(prop.value, scope_param_name, ns_names, out)
elif isinstance(prop, (JsRestElement, JsSpreadElement)):
_collect_assignment_targets(prop.argument, scope_param_name, ns_names, out)
elif isinstance(target, JsAssignmentPattern):
_collect_assignment_targets(target.left, scope_param_name, ns_names, out)
elif isinstance(target, (JsRestElement, JsSpreadElement)):
_collect_assignment_targets(target.argument, scope_param_name, ns_names, out)
def _redirect_assignment_target(
expr: Expression,
scope_param_name: str,
redirect_var: str,
) -> str | None:
"""
If *expr* is the single redirect-routing assignment `scope.redirect_var = scope.TARGET` (dot or
computed on either side), return the TARGET namespace name. Any other expression yields `None`.
"""
if not isinstance(expr, JsAssignmentExpression) or expr.operator != '=':
return None
lhs = expr.left
if not isinstance(lhs, JsMemberExpression):
return None
if not isinstance(lhs.object, JsIdentifier) or lhs.object.name != scope_param_name:
return None
if access_key(lhs) != redirect_var:
return None
rhs = expr.right
if not isinstance(rhs, JsMemberExpression):
return None
if not isinstance(rhs.object, JsIdentifier) or rhs.object.name != scope_param_name:
return None
return access_key(rhs)
def _scope_arg_object(
node: Node,
generator_name: str,
num_state_vars: int,
) -> JsObjectExpression | None:
"""
If *node* is a call `generator_name(states…, scopeObj, …)` whose argument at the scope position
(index *num_state_vars*) is an object literal, return that object literal. This is the
structural scope argument threaded through the shared generator by its wrapper functions.
"""
if not isinstance(node, JsCallExpression):
return None
if not isinstance(node.callee, JsIdentifier) or node.callee.name != generator_name:
return None
args = node.arguments
if len(args) <= num_state_vars:
return None
candidate = args[num_state_vars]
if isinstance(candidate, JsObjectExpression):
return candidate
return None
def _collect_namespaces(
switch_stmt: JsSwitchStatement,
scope_param_name: str | None,
scope_default_props: list[str],
redirect_var: str | None,
generator_name: str,
num_state_vars: int,
) -> set[str] | None:
"""
Collect the sibling namespace objects on the scope. A namespace is any of: a scope default, a
redirect target `scope.redirect_var = scope.TARGET` found anywhere in the switch (including
inside nested functions), or a key of an object-literal scope argument passed to the shared
generator. A scope argument whose keys `_scope_object_properties` cannot read gives `None`.
"""
namespaces: set[str] = set(scope_default_props)
for node in switch_stmt.walk():
if (
scope_param_name is not None
and redirect_var is not None
and isinstance(node, JsAssignmentExpression)
):
target = _redirect_assignment_target(node, scope_param_name, redirect_var)
if target is not None:
namespaces.add(target)
scope_arg = _scope_arg_object(node, generator_name, num_state_vars)
if scope_arg is None:
continue
if (properties := _scope_object_properties(scope_arg)) is None:
return None
namespaces.update(properties)
return namespaces
def _collect_namespace_homes(
switch_stmt: JsSwitchStatement,
scope_param_name: str | None,
ns_names: set[str],
) -> dict[str, tuple[str, ...]] | None:
"""
Determine the canonical home namespace of every proven namespace-local member, or `None` to
decline recovery when a member is written under two or more namespaces: under the `with`-redirect
its home depends on where the routing variable points when each use runs, which
redirect-independent qualification cannot express, so qualifying it could resolve to the wrong
binding. A member written under exactly one namespace maps to it; a name with no
namespace-defining write is free and stays bare once the `with` is dissolved.
"""
accumulated: dict[str, set[str]] = {}
for node in switch_stmt.walk():
if isinstance(node, JsAssignmentExpression):
_collect_assignment_targets(node.left, scope_param_name, ns_names, accumulated)
elif isinstance(node, JsUpdateExpression):
_collect_assignment_targets(node.argument, scope_param_name, ns_names, accumulated)
homes: dict[str, tuple[str, ...]] = {}
for member, home_set in accumulated.items():
if len(home_set) >= 2:
return None
homes[member] = (next(iter(home_set)),)
return homes
def _match_generator_cff(body: list[Statement], idx: int) -> _GeneratorCFFMatch | None:
"""
Starting at index *idx* in *body*, test whether the statement is a generator function
declaration matching the state machine CFF pattern, with its call site following. Its parameters
are the state variables, the scope parameter with its default, and at most the argument holder
behind it. A generator whose own code reads what its invocation binds
(`_reads_its_own_invocation`) does not match, and neither does one in strict code that declares
a function inside a block: the recovered code is read apart from the tree, where it cannot tell
that it is strict, so `_hoisted_names` would give such a function the Annex B copy only sloppy
code makes.
"""
stmt = body[idx]
if not isinstance(stmt, JsFunctionDeclaration):
return None
if not is_generator_function(stmt) or is_async_function(stmt):
return None
if stmt.id is None:
return None
gen_name = stmt.id.name
if stmt.body is None:
return None
params = stmt.params
if len(params) < 3:
return None
if _reads_its_own_invocation(stmt):
return None
if strict_mode_at(stmt) and _holds_a_block_function(stmt.body):
return None
scope_param_name: str | None = None
arg_var_name: str | None = None
state_var_names: list[str] = []
for p in params:
if not isinstance(p, JsIdentifier):
if (
scope_param_name is not None
or not isinstance(p, JsAssignmentPattern)
or not isinstance(p.left, JsIdentifier)
):
return None
scope_param_name = p.left.name
elif scope_param_name is None:
state_var_names.append(p.name)
elif arg_var_name is None:
arg_var_name = p.name
else:
return None
if not state_var_names:
return None
gen_body = stmt.body.body
if len(gen_body) != 1:
return None
while_stmt = gen_body[0]
if not isinstance(while_stmt, JsWhileStatement):
return None
if while_stmt.test is None or while_stmt.body is None:
return None
if not isinstance(while_stmt.test, JsBinaryExpression):
return None
if while_stmt.test.operator != '!==':
return None
lhs = while_stmt.test.left
rhs = while_stmt.test.right
if lhs is None or rhs is None:
return None
end_state: int | float | None = None
loop_sum: Expression = lhs
if _is_discriminant_sum(lhs, state_var_names):
end_state = _eval_number(rhs)
elif _is_discriminant_sum(rhs, state_var_names):
end_state = _eval_number(lhs)
loop_sum = rhs
if end_state is None:
return None
inner: Statement | None = while_stmt.body
if isinstance(inner, JsBlockStatement) and len(inner.body) == 1:
inner = inner.body[0]
if (scope_defaults := _extract_scope_default_props(params, scope_param_name)) is None:
return None
scope_default_props, scope_default_inits = scope_defaults
with_redirect_var: str | None = None
routed = isinstance(inner, JsWithStatement)
if isinstance(inner, JsWithStatement):
with_redirect_var = _extract_with_redirect_var(inner.object, scope_param_name)
inner = inner.body
if isinstance(inner, JsBlockStatement) and len(inner.body) == 1:
inner = inner.body[0]
switch_label: str | None = None
if isinstance(inner, JsLabeledStatement):
if inner.label is not None:
switch_label = inner.label.name
inner = inner.body
if not isinstance(inner, JsSwitchStatement):
return None
if inner.discriminant is None:
return None
if not _is_discriminant_sum(inner.discriminant, state_var_names):
return None
switch_stmt = inner
if not routed and _spells_a_slot_bare(switch_stmt, scope_param_name, scope_default_props):
return None
call_info = _find_generator_call_site(body, idx, gen_name)
if call_info is None:
return None
if len(call_info.initial_state) != len(state_var_names):
return None
namespaces: set[str] = set()
namespace_homes: dict[str, tuple[str, ...]] = {}
if with_redirect_var is not None and len(scope_default_props) == 1:
collected = _collect_namespaces(
switch_stmt,
scope_param_name,
scope_default_props,
with_redirect_var,
gen_name,
len(state_var_names),
)
if collected is None:
return None
namespaces = collected
homes = _collect_namespace_homes(switch_stmt, scope_param_name, namespaces)
if homes is None:
return None
namespace_homes = homes
return _GeneratorCFFMatch(
generator_name=gen_name,
state_var_names=state_var_names,
initial_state=call_info.initial_state,
end_state=end_state,
loop_sum=loop_sum,
switch_stmt=switch_stmt,
switch_label=switch_label,
scope_param_name=scope_param_name,
arg_var_name=arg_var_name,
did_return_var=call_info.did_return_var,
result_var=call_info.result_var,
gen_decl_index=idx,
scaffolding_end=call_info.scaffolding_end,
with_redirect_var=with_redirect_var,
routed=routed,
returns_value=call_info.returns_value,
guarded=call_info.guarded,
scope_default_props=scope_default_props,
scope_default_inits=scope_default_inits,
namespaces=namespaces,
namespace_homes=namespace_homes,
)
def _reads_its_own_invocation(generator: JsFunctionDeclaration) -> bool:
"""
Whether the code of *generator* reads what its own invocation binds: its receiver `this`, a
`super` reference, its `arguments` object or `new.target`, or whether it suspends with `yield`.
The recovery moves that code into the function around the call, or into a wrapper, where each
of these means that function's own.
"""
for node in walk_receiver_scope(generator):
if isinstance(node, (JsThisExpression, JsYieldExpression, JsMetaProperty)):
return True
if (
isinstance(node, JsIdentifier)
and node.name in ('arguments', 'super', 'new')
and is_use_position(node)
):
return True
return False
def _spells_a_slot_bare(
switch_stmt: JsSwitchStatement,
scope_param_name: str | None,
defaults: list[str],
) -> bool:
"""
Whether the code of a generator that reads its scope object through no `with` body spells the
name of a slot of that object bare. There the bare name means some other binding, and once the
strip turns `scope.K` into `K` it would name the slot instead.
"""
slots = set(defaults)
if scope_param_name is not None:
for member in _scope_members(switch_stmt, scope_param_name):
if (name := _deepest_property_name(member)) is not None:
slots.add(name)
return any(
isinstance(node, JsIdentifier) and node.name in slots and is_use_position(node)
for node in switch_stmt.walk()
)
def _spells_an_inherited_name_bare(
switch_stmt: JsSwitchStatement,
absent: Callable[[str], bool],
) -> bool:
"""
Whether the code of a generator that reads its scope object through a `with` body spells bare a
name that *absent* cannot prove missing from a plain object, such as `toString`, which every
object inherits. The `with` statement finds that name on its object before the binding of the
code around it, and the recovery removes the `with` statement.
"""
names = {
node.name for node in switch_stmt.walk()
if isinstance(node, JsIdentifier) and is_use_position(node)
}
inherited = frozenset(name for name in names if not absent(name))
return bool(inherited) and any(
isinstance(node, JsIdentifier)
and node.name in inherited
and node.name not in shadowed
and is_use_position(node)
for node, shadowed in _walk_scoped(switch_stmt, inherited)
)
def _holds_a_block_function(root: Node) -> bool:
"""
Whether a function is declared inside a block anywhere below *root*, where sloppy code would
copy its name into the function around the block.
"""
for node in root.walk():
if not isinstance(node, JsFunctionDeclaration):
continue
holder = node.parent
if isinstance(holder, JsScript):
continue
if isinstance(holder, JsBlockStatement) and isinstance(holder.parent, FUNCTION_NODES):
continue
return True
return False
def _did_return_reset(stmt: Statement) -> str | None:
"""
The flag *stmt* resets ahead of the generator's call, as `var didReturn;` or as
`didReturn = void 0;`, or `None`.
"""
if isinstance(stmt, JsVariableDeclaration):
decls = stmt.declarations
if (
len(decls) == 1
and isinstance(decls[0], JsVariableDeclarator)
and isinstance(decls[0].id, JsIdentifier)
and decls[0].init is None
):
return decls[0].id.name
if isinstance(stmt, JsExpressionStatement):
expr = stmt.expression
if (
isinstance(expr, JsAssignmentExpression)
and expr.operator == '='
and isinstance(expr.left, JsIdentifier)
and isinstance(expr.right, JsUnaryExpression)
and expr.right.operator == 'void'
and isinstance(expr.right.operand, JsNumericLiteral)
):
return expr.left.name
return None
def _is_result_guard(stmt: Statement, did_return_var: str, result_var: str) -> bool:
"""
Whether *stmt* is the guard `if (didReturn) { return result; }` and nothing more.
"""
if not isinstance(stmt, JsIfStatement) or stmt.alternate is not None:
return False
if not isinstance(stmt.test, JsIdentifier) or stmt.test.name != did_return_var:
return False
consequent = stmt.consequent
if isinstance(consequent, JsBlockStatement) and len(consequent.body) == 1:
consequent = consequent.body[0]
return (
isinstance(consequent, JsReturnStatement)
and isinstance(consequent.argument, JsIdentifier)
and consequent.argument.name == result_var
)
def _find_generator_call_site(
body: list[Statement],
gen_idx: int,
gen_name: str,
) -> _CallSiteInfo | None:
"""
Read the call of the generator declared at *gen_idx*, which the obfuscator writes right behind
the declaration:
var didReturn;
var result = genName(args)["next"]()["value"];
if (didReturn) { return result; }
The flag may be reset with `didReturn = void 0;` instead, the result assigned to a variable
declared elsewhere, and the call returned directly or made for nothing. The guard is part of
the scaffolding in exactly this form: one that does anything else stays, and the flag it reads
keeps the recovery from going ahead (`_scaffolding_is_private`). Returns a `_CallSiteInfo` or
`None`.
"""
pos = gen_idx + 1
did_return_var: str | None = None
if pos < len(body) and (did_return_var := _did_return_reset(body[pos])) is not None:
pos += 1
if pos >= len(body):
return None
call_expr = _extract_generator_call(body[pos], gen_name)
if call_expr is None:
return None
call_node, result_var = call_expr
initial_state: list[int | float] = []
for arg in call_node.arguments:
val = _eval_number(arg)
if val is None:
return None
initial_state.append(val)
scaffolding_end = pos
guarded = (
did_return_var is not None
and result_var is not None
and scaffolding_end + 1 < len(body)
and _is_result_guard(body[scaffolding_end + 1], did_return_var, result_var)
)
if guarded:
scaffolding_end += 1
return _CallSiteInfo(
initial_state,
did_return_var,
result_var,
scaffolding_end,
isinstance(body[pos], JsReturnStatement),
guarded,
)
class _GeneratorCallInfo(NamedTuple):
call_node: JsCallExpression
result_var: str | None
def _extract_generator_call(
stmt: Statement,
gen_name: str,
) -> _GeneratorCallInfo | None:
"""
Extract a generator call from a statement. Handles:
- var X = gen(...)["next"]()["value"];
- X = gen(...)["next"]()["value"];
- gen(...)["next"]()["value"];
- return gen(...)["next"]()["value"];
Returns a `(call, name)` pair (the inner call to gen and the result variable name) or `None`. A
result stored anywhere but in a plain name is refused: the recovery would drop the store.
"""
result_name: str | None = None
expr: Expression | None = None
if isinstance(stmt, JsVariableDeclaration):
if len(stmt.declarations) != 1:
return None
decl = stmt.declarations[0]
if not isinstance(decl, JsVariableDeclarator) or not isinstance(decl.id, JsIdentifier):
return None
result_name = decl.id.name
expr = decl.init
elif isinstance(stmt, JsExpressionStatement):
expr = stmt.expression
if isinstance(expr, JsAssignmentExpression):
if expr.operator != '=' or not isinstance(expr.left, JsIdentifier):
return None
result_name = expr.left.name
expr = expr.right
elif isinstance(stmt, JsReturnStatement):
expr = stmt.argument
else:
return None
if expr is None:
return None
gen_call = _unwrap_next_value(expr)
if gen_call is None:
return None
if not isinstance(gen_call.callee, JsIdentifier):
return None
if gen_call.callee.name != gen_name:
return None
return _GeneratorCallInfo(gen_call, result_name)
def _unwrap_next_value(node: Expression) -> JsCallExpression | None:
"""
Unwrap the `gen(...)` call from `gen(...).next().value`. Works when `next` and `value` are
accessed as properties or as keys.
"""
if not isinstance(node, JsMemberExpression):
return None
key = access_key(node)
if key != 'value':
return None
next_call = node.object
if not isinstance(next_call, JsCallExpression) or next_call.arguments:
return None
next_access = next_call.callee
if not isinstance(next_access, JsMemberExpression):
return None
if access_key(next_access) != 'next':
return None
gen_call = next_access.object
if not isinstance(gen_call, JsCallExpression):
return None
return gen_call
def _detect_wrapper_function(
node: Expression,
gen_name: str,
num_state_vars: int,
) -> _WrapperFunctionInfo | None:
"""
Test whether *node* is a wrapper function expression of the form:
function(...rest) { return gen(states..., scope, rest)["next"]()["value"]; }
The argument handed on after the scope has to be the wrapper's own last parameter, which is
what the recovered body reads the generator's argument holder as. Returns a
`_WrapperFunctionInfo` or `None`.
"""
if not isinstance(node, JsFunctionExpression):
return None
if node.body is None:
return None
body = node.body.body
if len(body) != 1:
return None
stmt = body[0]
if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
return None
gen_call = _unwrap_next_value(stmt.argument)
if gen_call is None:
return None
if not isinstance(gen_call.callee, JsIdentifier):
return None
if gen_call.callee.name != gen_name:
return None
args = gen_call.arguments
if len(args) < num_state_vars + 1:
return None
initial_state: list[int | float] = []
for arg in args[:num_state_vars]:
val = _eval_number(arg)
if val is None:
return None
initial_state.append(val)
rest_param_name: str | None = None
params = node.params
if params:
last_param = params[-1]
if isinstance(last_param, JsRestElement) and isinstance(last_param.argument, JsIdentifier):
rest_param_name = last_param.argument.name
elif isinstance(last_param, JsIdentifier):
rest_param_name = last_param.name
if (
len(args) != num_state_vars + 2
or not isinstance(handed := args[-1], JsIdentifier)
or handed.name != rest_param_name
):
return None
return _WrapperFunctionInfo(initial_state, rest_param_name, args[num_state_vars])
@dataclass
class _SMCase:
"""
A label of the switch: its test, or `None` for `default`, and the block a match runs, which is
that of the first body at or behind the label. The block is `None` where that body is not one
the recovery reads, or where no body follows and a match would leave the switch unchanged.
"""
test: Expression | None
block: _SMBlock | None
@dataclass
class _StateMachine:
"""
The labels of the switch in source order, the routing slots their tests read, the switch
discriminant, the sum of the state variables the loop compares with the *end_state*, and the
blocks of the bodies that declare a name, `None` for one the recovery cannot read. The labels
whose test reads nothing are indexed by the value that test makes (`_dispatch`).
"""
cases: list[_SMCase]
routing: frozenset[_SlotPath]
discriminant: Expression
loop_sum: Expression
end_state: float
declaring: list[_SMBlock | None]
constants: dict[float, int] = field(init=False, default_factory=dict)
variables: list[int] = field(init=False, default_factory=list)
def __post_init__(self):
for index, case in enumerate(self.cases):
if case.test is None:
continue
if (value := _eval_expr(case.test, _Env({}))) is None:
self.variables.append(index)
elif (number := _finite_number(value)) is not None:
self.constants.setdefault(number, index)
@property
def default(self) -> _SMBlock | None:
"""
The block the `default` label runs, or `None` where there is none or it runs no block the
recovery reads.
"""
for case in self.cases:
if case.test is None:
return case.block
return None
def _extract_state_blocks(
match: _GeneratorCFFMatch,
absent: Callable[[str], bool],
) -> _StateMachine | None:
"""
Read the labels of the switch in source order, each with the block it runs (`_read_block`).
A case body that declares a state variable's name for the block of the switch gives `None`:
that declaration takes the name in every case, test and transition alike.
"""
cases: list[JsSwitchCase] = []
for case in match.switch_stmt.cases:
if not isinstance(case, JsSwitchCase):
return None
cases.append(case)
declared = _block_bound_names([stmt for case in cases for stmt in case.body])
if not declared.isdisjoint(match.state_var_names):
return None
routing = _routing_paths(cases)
last_body = max((i for i, case in enumerate(cases) if case.body), default=-1)
labels: list[_SMCase] = []
pending: list[Expression | None] = []
declaring: list[_SMBlock | None] = []
for index, case in enumerate(cases):
pending.append(case.test)
if not case.body:
continue
block = _read_block(case.body, match, routing, index == last_body, absent)
labels.extend(_SMCase(test, block) for test in pending)
pending.clear()
if _declared_names_in_stmts(case.body):
declaring.append(block)
labels.extend(_SMCase(test, None) for test in pending)
discriminant = match.switch_stmt.discriminant
assert discriminant is not None
return _StateMachine(
labels, routing, discriminant, match.loop_sum, match.end_state, declaring
)
def _routing_paths(cases: list[JsSwitchCase]) -> frozenset[_SlotPath]:
"""
The routing slots of a switch: every property a case test reads along a chain of static keys
(`_slot_path`).
"""
paths: set[_SlotPath] = set()
for case in cases:
if case.test is None:
continue
for node in case.test.walk():
if not isinstance(node, JsMemberExpression):
continue
if isinstance(node.parent, JsMemberExpression) and node.parent.object is node:
continue
if (path := _slot_path(node)) is not None:
paths.add(path)
return frozenset(paths)
def _read_block(
stmts: list[Statement],
match: _GeneratorCFFMatch,
routing: frozenset[_SlotPath],
last: bool,
absent: Callable[[str], bool],
) -> _SMBlock | None:
"""
The block a case body makes (`_parse_case_body`) with the stores it makes to routing slots
(`_routing_stores`), or `None` where the recovery cannot read either.
"""
parsed = _parse_case_body(stmts, match.state_var_names, match.switch_label, last)
if parsed is None:
return None
stores = _routing_stores(stmts, match.scope_param_name, routing, absent)
if stores is None:
return None
payload, transition = parsed
return _SMBlock(payload, transition, stores)
def _store_pairs(expr: Expression) -> list[tuple[Expression, Expression]] | None:
"""
The pairs of target and value the plain assignment *expr* stores: the one pair of `t = v`, or
one pair for each element where it destructures an array literal of as many values into names
and members, as in `[t1, t2] = [v1, v2]`. A rest element, a default, a hole or a spread gives
`None`, and so does any other expression. The destructuring runs the array iterator, which is
trusted the way `_unpack_argument_stores` trusts it.
"""
if (
not isinstance(expr, JsAssignmentExpression)
or expr.operator != '='
or expr.left is None
or expr.right is None
):
return None
if not isinstance(expr.left, JsArrayPattern):
return [(expr.left, expr.right)]
if (
not isinstance(expr.right, JsArrayExpression)
or len(expr.left.elements) != len(expr.right.elements)
):
return None
pairs: list[tuple[Expression, Expression]] = []
for target, value in zip(expr.left.elements, expr.right.elements):
if (
not isinstance(target, (JsIdentifier, JsMemberExpression))
or value is None
or isinstance(value, JsSpreadElement)
):
return None
pairs.append((target, value))
return pairs
def _data_stores(
stmt: Statement,
scope_param_name: str | None,
) -> list[tuple[JsMemberExpression, _SlotPath, Expression]] | None:
"""
The stores where *stmt* does nothing but store values that hold data (`_is_inert`) under static
keys of the scope object (`_store_pairs`), as in
scope.M = {}, [scope.M.p, scope.M.q] = [85, -3];
each with its target, the path of that target (`_slot_path`), and the value. Any other
statement gives `None`.
"""
if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
return None
expr = stmt.expression
stores: list[tuple[JsMemberExpression, _SlotPath, Expression]] = []
for part in expr.expressions if isinstance(expr, JsSequenceExpression) else [expr]:
if (pairs := _store_pairs(part)) is None:
return None
for target, value in pairs:
if (
not isinstance(target, JsMemberExpression)
or (path := _slot_path(target)) is None
or path[0] != scope_param_name
or not _is_inert(value)
):
return None
stores.append((target, path, value))
return stores
def _routing_stores(
stmts: list[Statement],
scope_param_name: str | None,
routing: frozenset[_SlotPath],
absent: Callable[[str], bool],
) -> list[tuple[_SlotPath, _Value]] | None:
"""
The constants a case body stores in routing slots, in the order it stores them, or `None`
where it stores them in a way the obfuscator does not. The obfuscator puts one store of all
routing slots in front of the block the machine starts in,
[scope.M.p, scope.M.q] = [85, -3];
so every store to a routing slot has to stand among the statements that open the body and do
nothing but store data (`_data_stores`), has to store a number, and has to store it under a key
no prototype supplies (*absent*), where it makes a property of data. One of these statements
may also store a new object where a routing slot's object was, as long as the ones behind it
store every routing slot of that object again. Any other write of a routing slot or of an
object that holds one gives `None` (`_writes_a_routing_slot`), including one in the code of a
function these statements store.
"""
if not routing:
return []
stores: list[tuple[_SlotPath, _Value]] = []
handled: set[int] = set()
replaced: set[_SlotPath] = set()
for stmt in stmts:
if (pairs := _data_stores(stmt, scope_param_name)) is None:
break
for target, path, value in pairs:
if path in routing:
if (number := _eval_number(value)) is None or not absent(path[-1]):
return None
stores.append((path, number))
replaced.discard(path)
elif held := {slot for slot in routing if slot[:len(path)] == path}:
replaced |= held
else:
continue
handled.add(id(target))
if replaced or _writes_a_routing_slot(stmts, routing, handled):
return None
return stores
def _writes_a_routing_slot(
stmts: list[Statement],
routing: frozenset[_SlotPath],
handled: set[int],
) -> bool:
"""
Whether *stmts* write a routing slot or an object along its path anywhere but at the targets
whose ids *handled* holds: they store a property under any key along that path, a variable of
such a name, which the `with` statement may resolve to the slot or its object, or a property
under a key only known at runtime or under `__proto__` on an object of such a name. They may
also declare such a variable.
"""
names = {name for path in routing for name in path[1:]}
holders = {name for path in routing for name in path[:-1]}
for stmt in stmts:
for node in stmt.walk():
if isinstance(node, JsMemberExpression):
if id(node) in handled or not is_member_write_target(node):
continue
if (key := access_key(node)) in names:
return True
if (key is None or key == '__proto__') and _holder_name(node.object) in holders:
return True
elif isinstance(node, JsVariableDeclarator):
declared: set[str] = set()
_collect_binding_names(node.id, declared)
if not names.isdisjoint(declared):
return True
elif isinstance(node, JsIdentifier) and node.name in names and is_use_position(node):
if is_member_write_target(node):
return True
if (
isinstance(parent := node.parent, (JsFunctionDeclaration, JsClassDeclaration))
and parent.id is node
):
return True
return False
def _holder_name(node: Expression | None) -> str | None:
"""
The name an object is reached by: the key of the last access for a property, or the name of a
variable.
"""
if isinstance(node, JsMemberExpression):
return access_key(node)
if isinstance(node, JsIdentifier):
return node.name
return None
def _parse_case_body(
stmts: list[Statement],
var_names: list[str],
switch_label: str | None,
last: bool,
) -> tuple[list[Statement], _SMTransition] | None:
"""
Separate a case body into payload statements and the state transition the obfuscator ends it
with (`_split_case_body`). A body whose code stores a state variable anywhere but in its
transition (`_writes_a_state_variable`), or whose payload leaves the switch or the loop of the
machine (`_leaves_the_block`), gives `None`. Code behind a `return` or `throw` never runs, and
the obfuscator leaves the transition of such a block there.
"""
parsed = _split_case_body(stmts, var_names, switch_label, last)
if parsed is None:
return None
payload, transition = parsed
runs = payload if isinstance(transition, _SMExitTransition) else stmts
if _writes_a_state_variable(runs, var_names, _transition_assignments(transition)):
return None
if isinstance(transition, _SMConditionalTransition):
payload = [*payload, *transition.true_prefix, *transition.false_prefix]
if _leaves_the_block(payload):
return None
return parsed
def _split_case_body(
stmts: list[Statement],
var_names: list[str],
switch_label: str | None,
last: bool,
) -> tuple[list[Statement], _SMTransition] | None:
"""
Separate a case body into payload statements and a state transition. A `return` or `throw` at
the top level ends the block, and nothing behind it runs; but what it declares is declared all
the same, and a body whose code behind that statement declares a name gives `None`. Any other
body ends in the assignments of its transition, directly or in both branches of a final `if`
statement, and then in a `break` of the switch, which only the *last* body may leave out: every
other body runs on into the next one without it. A body with no transition, which would
dispatch the same state again forever, gives `None`.
"""
for index, stmt in enumerate(stmts):
if isinstance(stmt, (JsReturnStatement, JsThrowStatement)):
if _declared_names_in_stmts(stmts[index + 1:]):
return None
return (stmts[:index + 1], _SMExitTransition())
stmts, terminated = _strip_trailing_break(stmts, switch_label)
transition: _SMTransition
if stmts and isinstance(final := stmts[-1], JsIfStatement) and final.alternate is not None:
if (conditional := _parse_conditional_transition(final, var_names, switch_label)) is None:
return None
transition, branches_terminated = conditional
terminated = terminated or branches_terminated
payload = stmts[:-1]
elif (split := _split_transition(stmts, var_names)) is not None:
payload, assignments = split
transition = _SMLinearTransition(assignments=assignments)
else:
return None
if not terminated and not last:
return None
return (payload, transition)
def _strip_trailing_break(
stmts: list[Statement],
label: str | None,
) -> tuple[list[Statement], bool]:
"""
Remove a trailing `break` that leaves the switch, unlabeled or naming the switch *label*, and
tell whether there was one.
"""
if stmts and isinstance(last := stmts[-1], JsBreakStatement):
if last.label is None or (label is not None and last.label.name == label):
return stmts[:-1], True
return stmts, False
def _state_assignment(expr: Expression, var_names: list[str]) -> _SMRawAssignment | None:
"""
The assignment *expr* makes to a state variable with `=` or `+=`, or `None`.
"""
if (
isinstance(expr, JsAssignmentExpression)
and isinstance(expr.left, JsIdentifier)
and expr.left.name in var_names
and expr.operator in ('=', '+=')
and expr.right is not None
):
return _SMRawAssignment(expr.left.name, expr.operator, expr.right, expr)
return None
def _split_transition(
stmts: list[Statement],
var_names: list[str],
) -> tuple[list[Statement], list[_SMRawAssignment]] | None:
"""
Split statements that end in the assignments of a transition into the statements before them
and those assignments, in the order they run. The transition is the longest run of state
variable assignments at the end, as statements of their own or as the last expressions of a
comma sequence, like the redirect store and state updates in
scope.W = scope.NS, a += 5, b += -3;
whose leading expressions stay behind, cloned into a statement of their own so that the tree
the machine is read from keeps them. Returns `None` where the statements do not end in such an
assignment.
"""
assignments: list[_SMRawAssignment] = []
index = len(stmts)
while index > 0:
stmt = stmts[index - 1]
if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
break
expr = stmt.expression
parts = list(expr.expressions) if isinstance(expr, JsSequenceExpression) else [expr]
split = len(parts)
run: list[_SMRawAssignment] = []
while split > 0 and (assignment := _state_assignment(parts[split - 1], var_names)):
run.append(assignment)
split -= 1
if not run:
break
run.reverse()
assignments[:0] = run
index -= 1
if split > 0:
head = [_clone_node(part) for part in parts[:split]]
leading = head[0] if split == 1 else JsSequenceExpression(expressions=head)
return [*stmts[:index], JsExpressionStatement(expression=leading)], assignments
if not assignments:
return None
return stmts[:index], assignments
def _transition_assignments(transition: _SMTransition) -> list[_SMRawAssignment]:
"""
The assignments of a transition, of both branches where it has two.
"""
if isinstance(transition, _SMLinearTransition):
return transition.assignments
if isinstance(transition, _SMConditionalTransition):
return transition.true_assignments + transition.false_assignments
return []
def _writes_a_state_variable(
stmts: list[Statement],
var_names: list[str],
transition: list[_SMRawAssignment],
) -> bool:
"""
Whether *stmts* store to a state variable anywhere but in the assignments of the *transition*,
or declare one, where nothing between them and the store binds its name anew. The recovery
reads the state only from the transition that ends a block, and puts each state variable's
value at entry in place of its every read.
"""
watched = frozenset(var_names)
allowed = {id(assignment.node) for assignment in transition}
for stmt in stmts:
for node, shadowed in _walk_scoped(stmt, watched):
names = watched - shadowed
if isinstance(node, JsVariableDeclarator):
declared: set[str] = set()
_collect_binding_names(node.id, declared)
if not names.isdisjoint(declared):
return True
elif isinstance(node, (JsFunctionDeclaration, JsClassDeclaration)):
if node.id is not None and node.id.name in names:
return True
elif (
isinstance(node, JsIdentifier)
and node.name in names
and is_member_write_target(node)
and id(node.parent) not in allowed
):
return True
return False
def _leaves_the_block(stmts: list[Statement]) -> bool:
"""
Whether *stmts* hold a `break` or `continue` that leaves them: one naming a label they do not
declare, or one that no loop or `switch` among them takes. In a case body, it would leave the
switch or the loop of the machine, neither of which the recovered code has.
"""
stack: list[tuple[Node, frozenset[str], bool, bool]] = [
(stmt, frozenset(), False, False) for stmt in stmts
]
while stack:
node, labels, breaks, continues = stack.pop()
if isinstance(node, (JsBreakStatement, JsContinueStatement)):
if node.label is not None:
if node.label.name not in labels:
return True
elif not (breaks if isinstance(node, JsBreakStatement) else continues):
return True
continue
if isinstance(node, JsLabeledStatement) and node.label is not None:
labels = labels | {node.label.name}
if isinstance(node, LOOP_NODES):
breaks = continues = True
elif isinstance(node, JsSwitchStatement):
breaks = True
stack.extend(
(child, labels, breaks, continues) for child in node.children()
if isinstance(child, (Statement, JsSwitchCase, JsCatchClause))
and not isinstance(child, (*FUNCTION_NODES, JsClassDeclaration))
)
return False
def _apply_raw_transition(assignments: list[_SMRawAssignment], current: _Env) -> _Env | None:
"""
Evaluate the assignments of a transition in order, each seeing the results of those before it,
and give what the machine reads after them, or `None` where a state variable would not hold a
finite number.
"""
states = dict(current.states)
for assign in assignments:
value = _eval_expr(assign.rhs, _Env(states, current.slots))
if value is not None and assign.operator == '+=':
value = eval_binary_op('+', states[assign.name], _to_number(value))
if (number := _finite_number(value)) is None:
return None
states[assign.name] = number
return _Env(states, current.slots)
def _block_stmts(node: Statement) -> list[Statement]:
if isinstance(node, JsBlockStatement):
return list(node.body)
return [node]
def _parse_conditional_transition(
if_stmt: JsIfStatement,
var_names: list[str],
switch_label: str | None,
) -> tuple[_SMConditionalTransition, bool] | None:
"""
Parse an if/else whose branches both end in the assignments of a transition, and tell whether
both branches then leave the switch with a `break`.
"""
if if_stmt.test is None or if_stmt.consequent is None or if_stmt.alternate is None:
return None
true_stmts, true_terminated = _strip_trailing_break(
_block_stmts(if_stmt.consequent), switch_label
)
false_stmts, false_terminated = _strip_trailing_break(
_block_stmts(if_stmt.alternate), switch_label
)
true_split = _split_transition(true_stmts, var_names)
false_split = _split_transition(false_stmts, var_names)
if true_split is None or false_split is None:
return None
true_prefix, true_assigns = true_split
false_prefix, false_assigns = false_split
transition = _SMConditionalTransition(
condition=if_stmt.test,
true_assignments=true_assigns,
false_assignments=false_assigns,
true_prefix=true_prefix,
false_prefix=false_prefix,
)
return transition, true_terminated and false_terminated
def _dispatch(machine: _StateMachine, env: _Env) -> _SMBlock | None:
"""
The block the switch runs for what the machine reads: it compares the discriminant with the
case tests in source order and with `===`, and runs the `default` label where none matches.
Only the tests that read something are evaluated here, and only those ahead of the first label
whose constant test matches. Where a test ahead of the match cannot be evaluated, or the
matched label runs no block the recovery reads (`_SMCase`), this gives `None`.
"""
if (discriminant := _finite_number(_eval_expr(machine.discriminant, env))) is None:
return None
first = machine.constants.get(discriminant)
for index in machine.variables:
if first is not None and index > first:
break
test = machine.cases[index].test
assert test is not None
if (value := _eval_expr(test, env)) is None:
return None
if _strictly_equal(value, discriminant):
return machine.cases[index].block
if first is not None:
return machine.cases[first].block
return machine.default
def _ends(machine: _StateMachine, env: _Env) -> bool | None:
"""
Whether the loop of the machine stops for what the run reads, or `None` where the sum it
compares cannot be evaluated.
"""
if (total := _finite_number(_eval_expr(machine.loop_sum, env))) is None:
return None
return total == machine.end_state
def _apply_initial_state(var_names: list[str], values: list[int | float]) -> _StateEnv:
return dict(zip(var_names, values))
def _initial_slots(
routing: frozenset[_SlotPath],
scope_param_name: str | None,
created: dict[str, Expression],
handed_on: set[str],
creator: _Slots,
absent: Callable[[str], bool],
) -> _Slots:
"""
The values the routing slots hold when a run starts on a scope object that an object literal
makes: *created* maps the keys of its own properties to their values, and *handed_on* names the
keys under which it holds the objects of the run that calls it, whose routing slots hold
*creator*. A slot of a namespace the literal makes holds what that namespace's own literal gives
it (`_scope_object_properties`), and a slot neither literal holds reads `undefined` where
*absent* proves that no prototype supplies its key. Every other slot is left unknown.
"""
slots: _Slots = {}
for path in routing:
value: _Value | None = None
if path[0] != scope_param_name:
continue
if len(path) == 2 and (key := path[1]) not in handed_on:
if key in created:
value = _eval_number(created[key])
elif absent(key):
value = _UNDEFINED
elif len(path) == 3:
home, key = path[1:]
if home in handed_on:
value = creator.get(path)
elif (
isinstance(namespace := created.get(home), JsObjectExpression)
and (properties := _scope_object_properties(namespace)) is not None
):
if key in properties:
value = _eval_number(properties[key])
elif absent(key):
value = _UNDEFINED
if value is not None:
slots[path] = value
return slots
def _process_payload(
stmts: list[Statement],
state: _StateEnv,
match: _GeneratorCFFMatch,
) -> list[Statement]:
"""
Process statements a block runs, its payload or a branch prefix of its transition, through the
standard pipeline: substitute the state, record the scope slots they use, strip the scope
prefix, qualify, and filter the redirect bookkeeping. Returns the processed statements ready
for emission.
"""
if not stmts:
return []
result = _substitute_state_vars(stmts, state)
_collect_scope_props(result, match)
result = _strip_scope_param_prefix(result, match.scope_param_name)
result = _qualify_with_identifiers(result, match)
return _filter_redirect_var_assignments(result, match)
_VIRTUAL_EXIT: int = -1
@dataclass
class _CFGNode:
"""
A node in the control flow graph derived from the state machine: one block, keyed by its
identity (`id(block)`), with the one state every arrival at it finds. A block reached again is
the same node, which is how a loop shows as a back-edge.
"""
node_id: int
payload: list[Statement]
condition: Expression | None
successors: list[int] = field(default_factory=list)
predecessors: list[int] = field(default_factory=list)
true_prefix_payload: list[Statement] = field(default_factory=list)
false_prefix_payload: list[Statement] = field(default_factory=list)
@dataclass
class _CFG:
"""
Control flow graph built from symbolic execution of state machine transitions.
"""
nodes: dict[int, _CFGNode]
entry: int
exit: int
@dataclass
class _NaturalLoop:
"""
A natural loop identified by a back-edge in the CFG.
"""
header: int
body: set[int]
tails: list[int]
exits: set[int]
def _build_cfg(
machine: _StateMachine,
entry: _Env,
match: _GeneratorCFFMatch,
main: bool,
) -> tuple[_CFG, _Slots] | None:
"""
Build a control flow graph by BFS from the block a run enters, where the machine reads *entry*.
Nodes are keyed by the identity of the `_SMBlock` they correspond to, so a block reached twice
is one node and a loop is a back-edge. The obfuscator gives every block one state, so every
arrival at a block has to find what the machine reads there as the first one did
(`_Env.same_as`); where it does not, where the loop would not run at all or the next block
cannot be told (`_dispatch`), or where the run reaches more than `_MAX_BLOCKS` blocks, this
gives `None`. Only the *main* run may store routing slots, and only in the block it enters
(`_routing_stores`), so they hold what that block leaves in them from before any wrapper
exists. The recovery leaves out every block the main run does not reach, and a name one of them
declares would go with it, so the main run has to reach each of them. Returns the CFG and those
values.
"""
if _ends(machine, entry) is not False:
return None
entry_block = _dispatch(machine, entry)
if entry_block is None:
return None
entry_id = id(entry_block)
nodes: dict[int, _CFGNode] = {}
arrivals: dict[int, _Env] = {entry_id: entry}
queue: deque[_SMBlock] = deque([entry_block])
settled = entry.slots
def arrive(env: _Env | None) -> int | None:
if env is None or (ended := _ends(machine, env)) is None:
return None
if ended:
return _VIRTUAL_EXIT
if (block := _dispatch(machine, env)) is None:
return None
node_id = id(block)
if node_id not in arrivals:
if len(arrivals) >= _MAX_BLOCKS:
return None
arrivals[node_id] = env
queue.append(block)
elif not arrivals[node_id].same_as(env):
return None
return node_id
while queue:
block = queue.popleft()
node_id = id(block)
env = arrivals[node_id]
if block.routing_stores:
if not main or node_id != entry_id:
return None
settled = {**env.slots, **dict(block.routing_stores)}
env = _Env(env.states, settled)
payload = _process_payload(block.payload, env.states, match)
condition: Expression | None = None
successors: list[int] = []
true_prefix_payload: list[Statement] = []
false_prefix_payload: list[Statement] = []
transition = block.transition
if isinstance(transition, _SMExitTransition):
successors = [_VIRTUAL_EXIT]
elif isinstance(transition, _SMLinearTransition):
if (target := arrive(_apply_raw_transition(transition.assignments, env))) is None:
return None
successors = [target]
elif isinstance(transition, _SMConditionalTransition):
true_id = arrive(_apply_raw_transition(transition.true_assignments, env))
false_id = arrive(_apply_raw_transition(transition.false_assignments, env))
if true_id is None or false_id is None:
return None
successors = [true_id, false_id]
true_prefix_payload = _process_payload(transition.true_prefix, env.states, match)
false_prefix_payload = _process_payload(transition.false_prefix, env.states, match)
condition = _qualify_condition(transition.condition, env.states, match)
node = _CFGNode(
node_id=node_id,
payload=payload,
condition=condition,
successors=successors,
true_prefix_payload=true_prefix_payload,
false_prefix_payload=false_prefix_payload,
)
nodes[node_id] = node
if main and any(block is None or id(block) not in arrivals for block in machine.declaring):
return None
exit_node = _CFGNode(node_id=_VIRTUAL_EXIT, payload=[], condition=None)
nodes[_VIRTUAL_EXIT] = exit_node
for n in nodes.values():
for succ_id in n.successors:
nodes[succ_id].predecessors.append(n.node_id)
return (_CFG(nodes=nodes, entry=entry_id, exit=_VIRTUAL_EXIT), settled)
def _compute_idom(cfg: _CFG) -> dict[int, int | None]:
"""
Compute immediate dominators using the Cooper-Harvey-Kennedy iterative algorithm.
"""
entry = cfg.entry
order = _reverse_postorder(cfg)
node_to_idx = {d: i for i, d in enumerate(order)}
idom: dict[int, int | None] = {entry: None}
def intersect(a: int, b: int) -> int:
ai = node_to_idx[a]
bi = node_to_idx[b]
while ai != bi:
while ai > bi:
a = idom[a] # type: ignore
ai = node_to_idx[a]
while bi > ai:
b = idom[b] # type: ignore
bi = node_to_idx[b]
return a
changed = True
while changed:
changed = False
for disc in order:
if disc == entry:
continue
node = cfg.nodes[disc]
preds = [p for p in node.predecessors if p in idom]
if not preds:
continue
new_idom = preds[0]
for p in preds[1:]:
new_idom = intersect(new_idom, p)
if idom.get(disc) != new_idom:
idom[disc] = new_idom
changed = True
return idom
def _reverse_postorder(cfg: _CFG) -> list[int]:
"""
Compute reverse postorder traversal of the CFG from entry.
"""
visited: set[int] = set()
order: list[int] = []
def dfs(disc: int):
stack: list[tuple[int, int]] = [(disc, 0)]
while stack:
current, idx = stack.pop()
if idx == 0:
if current in visited:
continue
visited.add(current)
node = cfg.nodes.get(current)
if node is None:
order.append(current)
continue
succs = [s for s in node.successors if s in cfg.nodes]
if idx < len(succs):
stack.append((current, idx + 1))
s = succs[idx]
if s not in visited:
stack.append((s, 0))
else:
order.append(current)
dfs(cfg.entry)
order.reverse()
return order
def _dominates(idom: dict[int, int | None], a: int, b: int) -> bool:
current = b
while current is not None:
if current == a:
return True
current = idom.get(current)
return False
def _compute_ipdom(
cfg: _CFG,
exit_id: int,
region: set[int] | None = None,
) -> dict[int, int | None]:
"""
Compute immediate post-dominators using Cooper-Harvey-Kennedy on the reverse CFG.
Post-dominator of X = first node Y that ALL paths from X to exit must pass through.
"""
exit_preds: list[int] = []
if exit_id not in cfg.nodes:
for nid, node in cfg.nodes.items():
if region is not None and nid not in region:
continue
if exit_id in node.successors:
exit_preds.append(nid)
visited: set[int] = set()
rpo: list[int] = []
def _get_reverse_succs(nid: int) -> list[int]:
node = cfg.nodes.get(nid)
if node is None:
if nid == exit_id:
return exit_preds
return []
preds = node.predecessors
if region is not None:
preds = [p for p in preds if p in region]
return preds
stack: list[tuple[int, int]] = [(exit_id, 0)]
while stack:
current, idx = stack.pop()
if idx == 0:
if current in visited:
continue
visited.add(current)
preds = _get_reverse_succs(current)
if idx < len(preds):
stack.append((current, idx + 1))
p = preds[idx]
if p not in visited:
stack.append((p, 0))
else:
rpo.append(current)
rpo.reverse()
node_to_idx = {d: i for i, d in enumerate(rpo)}
ipdom: dict[int, int | None] = {exit_id: None}
def intersect(a: int, b: int) -> int:
ai: int = node_to_idx[a]
bi: int = node_to_idx[b]
while ai != bi:
while ai > bi:
a = ipdom[a] # type: ignore
ai = node_to_idx[a]
while bi > ai:
b = ipdom[b] # type: ignore
bi = node_to_idx[b]
return a
changed = True
while changed:
changed = False
for disc in rpo:
if disc == exit_id:
continue
node = cfg.nodes.get(disc)
if node is None:
continue
succs = [s for s in node.successors if s in ipdom]
if region is not None:
succs = [s for s in succs if s in region or s == exit_id]
if not succs:
continue
new_ipdom = succs[0]
for s in succs[1:]:
new_ipdom = intersect(new_ipdom, s)
if ipdom.get(disc) != new_ipdom:
ipdom[disc] = new_ipdom
changed = True
return ipdom
def _find_loops(cfg: _CFG, idom: dict[int, int | None]) -> list[_NaturalLoop]:
"""
Identify natural loops from back-edges. A back-edge is (tail -> header) where header
dominates tail. The loop body is the set of nodes that can reach the tail without leaving
the header's dominance.
"""
back_edges: list[tuple[int, int]] = []
for disc, node in cfg.nodes.items():
for succ in node.successors:
if succ in cfg.nodes and _dominates(idom, succ, disc):
back_edges.append((disc, succ))
loops_by_header: dict[int, _NaturalLoop] = {}
for tail, header in back_edges:
if header not in loops_by_header:
body = _compute_loop_body(cfg, header, tail)
exits: set[int] = set()
for b in body:
n = cfg.nodes[b]
for s in n.successors:
if s not in body and s in cfg.nodes:
exits.add(b)
loops_by_header[header] = _NaturalLoop(
header=header, body=body, tails=[tail], exits=exits,
)
else:
loop = loops_by_header[header]
loop.tails.append(tail)
extra = _compute_loop_body(cfg, header, tail)
loop.body |= extra
for b in loop.body:
n = cfg.nodes[b]
for s in n.successors:
if s not in loop.body and s in cfg.nodes:
loop.exits.add(b)
return list(loops_by_header.values())
def _compute_loop_body(cfg: _CFG, header: int, tail: int) -> set[int]:
"""
Compute the natural loop body: all nodes that can reach `tail` without going through
`header`, plus `header` itself.
"""
body: set[int] = {header}
if tail == header:
return body
body.add(tail)
worklist: list[int] = [tail]
while worklist:
node_disc = worklist.pop()
n = cfg.nodes.get(node_disc)
if n is None:
continue
for pred in n.predecessors:
if pred not in body and pred in cfg.nodes:
body.add(pred)
worklist.append(pred)
return body
def _structural_analysis(
cfg: _CFG,
idom: dict[int, int | None],
loops: list[_NaturalLoop],
) -> list[Statement]:
"""
Recover structured control flow from the CFG using region-based structural analysis.
Process loops innermost-first, then structure acyclic regions.
"""
sorted_loops = _sort_loops_innermost_first(loops)
collapsed: dict[int, list[Statement]] = {}
loop_headers: set[int] = set()
for loop in sorted_loops:
loop_headers.add(loop.header)
body_stmts = _structure_loop(cfg, loop, idom, collapsed)
collapsed[loop.header] = body_stmts
for body_node in loop.body:
if body_node != loop.header and body_node not in collapsed:
collapsed[body_node] = []
return _structure_acyclic_region(cfg, cfg.entry, cfg.exit, idom, collapsed, loop_headers)
def _sort_loops_innermost_first(loops: list[_NaturalLoop]) -> list[_NaturalLoop]:
"""
Sort loops so that inner (smaller body) loops are processed before outer ones.
"""
return sorted(loops, key=lambda lp: len(lp.body))
def _structure_loop(
cfg: _CFG,
loop: _NaturalLoop,
idom: dict[int, int | None],
collapsed: dict[int, list[Statement]],
) -> list[Statement]:
"""
Structure a single natural loop into a while/do-while statement.
"""
header = loop.header
header_node = cfg.nodes[header]
if (
header_node.condition is not None
and len(header_node.successors) == 2
and not header_node.payload
):
true_succ, false_succ = header_node.successors
if true_succ not in loop.body and true_succ in cfg.nodes:
body_entry = false_succ
condition = JsUnaryExpression(operator='!', operand=header_node.condition, prefix=True)
body_prefix = header_node.false_prefix_payload
exit_prefix = header_node.true_prefix_payload
elif false_succ not in loop.body and false_succ in cfg.nodes:
body_entry = true_succ
condition = header_node.condition
body_prefix = header_node.true_prefix_payload
exit_prefix = header_node.false_prefix_payload
else:
return _structure_loop_infinite(cfg, loop, idom, collapsed)
body_stmts = _structure_acyclic_region(
cfg, body_entry, header, idom, collapsed, set(),
loop_body=loop.body,
)
body_stmts = list(header_node.payload) + list(body_prefix) + body_stmts
while_stmt = JsWhileStatement(
test=condition,
body=JsBlockStatement(body=body_stmts),
)
return [while_stmt] + list(exit_prefix)
return _structure_loop_infinite(cfg, loop, idom, collapsed)
def _structure_loop_infinite(
cfg: _CFG,
loop: _NaturalLoop,
idom: dict[int, int | None],
collapsed: dict[int, list[Statement]],
) -> list[Statement]:
"""
Structure a loop that doesn't have a simple while-condition as `while(true)` with breaks.
"""
header = loop.header
body_stmts = _structure_region_nodes(cfg, header, idom, collapsed, loop.body)
while_stmt = JsWhileStatement(
test=JsBooleanLiteral(value=True),
body=JsBlockStatement(body=body_stmts),
)
return [while_stmt]
def _structure_acyclic_region(
cfg: _CFG,
entry: int,
exit_disc: int,
idom: dict[int, int | None],
collapsed: dict[int, list[Statement]],
loop_headers: set[int],
loop_body: set[int] | None = None,
_visited: set[int] | None = None,
) -> list[Statement]:
"""
Structure an acyclic region from `entry` to `exit_disc` into a statement sequence.
Handles if/else patterns using post-dominator-based join detection.
"""
result: list[Statement] = []
visited: set[int] = _visited if _visited is not None else set()
worklist: deque[int] = deque([entry])
while worklist:
disc = worklist.popleft()
if disc == exit_disc or disc == _VIRTUAL_EXIT:
continue
if disc in visited:
continue
if loop_body is not None and disc not in loop_body:
continue
visited.add(disc)
if disc in collapsed:
result.extend(collapsed[disc])
node = cfg.nodes[disc]
for s in node.successors:
if s not in visited and s != exit_disc and s != _VIRTUAL_EXIT:
if loop_body is None or s in loop_body:
worklist.append(s)
continue
node = cfg.nodes.get(disc)
if node is None:
continue
if node.condition is not None and len(node.successors) == 2:
result.extend(node.payload)
true_succ, false_succ = node.successors
join = _find_acyclic_join(cfg, disc, exit_disc, loop_body)
true_stmts: list[Statement] = list(node.true_prefix_payload)
true_visited = set(visited)
if true_succ != join and true_succ not in visited:
true_stmts.extend(_structure_acyclic_region(
cfg, true_succ, join, idom, collapsed, loop_headers, loop_body, true_visited,
))
false_stmts: list[Statement] = list(node.false_prefix_payload)
false_visited = set(visited)
if false_succ != join and false_succ not in visited:
false_stmts.extend(_structure_acyclic_region(
cfg, false_succ, join, idom, collapsed, loop_headers, loop_body, false_visited,
))
visited.update(true_visited)
visited.update(false_visited)
result.append(_build_js_if(node.condition, true_stmts, false_stmts))
if join != _VIRTUAL_EXIT and join != exit_disc and join not in visited:
worklist.appendleft(join)
else:
result.extend(node.payload)
for s in node.successors:
if s == exit_disc or s == _VIRTUAL_EXIT:
continue
if s in visited:
continue
if loop_body is not None and s not in loop_body:
result.append(JsBreakStatement())
continue
worklist.append(s)
return result
def _find_acyclic_join(
cfg: _CFG,
cond_disc: int,
region_exit: int,
loop_body: set[int] | None,
) -> int:
"""
Find the join point of a conditional by computing its immediate post-dominator within
the region. The ipdom is the first node where ALL paths from both successors converge.
"""
region: set[int] = set()
queue: deque[int] = deque([cond_disc])
while queue:
d = queue.popleft()
if d in region or d == _VIRTUAL_EXIT:
continue
if d == region_exit:
region.add(d)
continue
if loop_body is not None and d not in loop_body:
continue
region.add(d)
node = cfg.nodes.get(d)
if node is not None:
for s in node.successors:
if s not in region:
queue.append(s)
if not region or cond_disc not in region:
return region_exit
region.add(region_exit)
ipdom = _compute_ipdom(cfg, region_exit, region)
join = ipdom.get(cond_disc)
if join is None or (loop_body is not None and join not in loop_body and join != region_exit):
return region_exit
return join
def _structure_region_nodes(
cfg: _CFG,
header: int,
idom: dict[int, int | None],
collapsed: dict[int, list[Statement]],
loop_body: set[int],
) -> list[Statement]:
"""
Structure a set of CFG nodes that form a loop body, starting from the header.
"""
result: list[Statement] = []
visited: set[int] = set()
worklist: deque[int] = deque([header])
while worklist:
disc = worklist.popleft()
if disc in visited:
continue
if disc not in loop_body:
result.append(JsBreakStatement())
continue
visited.add(disc)
if disc in collapsed:
result.extend(collapsed[disc])
node = cfg.nodes[disc]
for s in node.successors:
if s not in visited and s in loop_body:
worklist.append(s)
continue
node = cfg.nodes.get(disc)
if node is None:
continue
if node.condition is not None and len(node.successors) == 2:
result.extend(node.payload)
true_succ, false_succ = node.successors
true_in_loop = true_succ in loop_body
false_in_loop = false_succ in loop_body
if true_succ == header:
if false_succ not in loop_body:
neg = JsUnaryExpression(operator='!', operand=node.condition, prefix=True)
break_body = list(node.false_prefix_payload) + [JsBreakStatement()]
result.append(JsIfStatement(
test=neg,
consequent=JsBlockStatement(body=break_body),
))
result.extend(node.true_prefix_payload)
else:
continue_body = list(node.true_prefix_payload) + [JsContinueStatement()]
result.append(JsIfStatement(
test=node.condition,
consequent=JsBlockStatement(body=continue_body),
))
result.extend(node.false_prefix_payload)
worklist.append(false_succ)
continue
elif false_succ == header:
if true_succ not in loop_body:
break_body = list(node.true_prefix_payload) + [JsBreakStatement()]
result.append(JsIfStatement(
test=node.condition,
consequent=JsBlockStatement(body=break_body),
))
result.extend(node.false_prefix_payload)
else:
neg = JsUnaryExpression(operator='!', operand=node.condition, prefix=True)
continue_body = list(node.false_prefix_payload) + [JsContinueStatement()]
result.append(JsIfStatement(
test=neg,
consequent=JsBlockStatement(body=continue_body),
))
result.extend(node.true_prefix_payload)
worklist.append(true_succ)
continue
if not true_in_loop and not false_in_loop:
if node.true_prefix_payload or node.false_prefix_payload:
true_body = list(node.true_prefix_payload) + [JsBreakStatement()]
false_body = list(node.false_prefix_payload) + [JsBreakStatement()]
result.append(_build_js_if(node.condition, true_body, false_body))
else:
result.append(_build_js_if(node.condition, [], []))
result.append(JsBreakStatement())
continue
if not true_in_loop:
break_body = list(node.true_prefix_payload) + [JsBreakStatement()]
result.append(JsIfStatement(
test=node.condition,
consequent=JsBlockStatement(body=break_body),
))
result.extend(node.false_prefix_payload)
worklist.append(false_succ)
continue
if not false_in_loop:
neg = JsUnaryExpression(operator='!', operand=node.condition, prefix=True)
break_body = list(node.false_prefix_payload) + [JsBreakStatement()]
result.append(JsIfStatement(
test=neg,
consequent=JsBlockStatement(body=break_body),
))
result.extend(node.true_prefix_payload)
worklist.append(true_succ)
continue
join = _find_acyclic_join(cfg, disc, header, loop_body)
true_stmts: list[Statement] = list(node.true_prefix_payload)
true_visited = set(visited)
if true_succ != join and true_succ not in visited:
true_stmts.extend(_structure_acyclic_region(
cfg, true_succ, join, idom, collapsed, set(), loop_body, true_visited,
))
false_stmts: list[Statement] = list(node.false_prefix_payload)
false_visited = set(visited)
if false_succ != join and false_succ not in visited:
false_stmts.extend(_structure_acyclic_region(
cfg, false_succ, join, idom, collapsed, set(), loop_body, false_visited,
))
visited.update(true_visited)
visited.update(false_visited)
result.append(_build_js_if(node.condition, true_stmts, false_stmts))
if join != header and join in loop_body and join not in visited:
worklist.appendleft(join)
else:
result.extend(node.payload)
for s in node.successors:
if s == header:
continue
if s not in loop_body:
result.append(JsBreakStatement())
continue
if s in visited:
continue
worklist.append(s)
return result
def _substitute_state_vars(stmts: list[Statement], env: _StateEnv) -> list[Statement]:
"""
Clone statements and replace state variable identifiers with numeric literals. Stops at
function boundaries to avoid replacing reused names in nested scopes.
"""
result: list[Statement] = []
for stmt in stmts:
cloned = _clone_node(stmt)
_substitute_in_scope(cloned, env)
result.append(cloned)
return result
def _parameter_names(func: JsFunctionNode) -> set[str]:
"""
The names *func* binds for its parameter list: its parameters and the name of a function
expression. A default value is evaluated in a scope of its own and does not see what the body
declares.
"""
names: set[str] = set()
if isinstance(func, JsFunctionExpression) and func.id is not None:
names.add(func.id.name)
for param in func.params:
_collect_binding_names(param, names)
return names
def _hoisted_names(statements: list[Statement], home: JsFunctionNode | None) -> set[str]:
"""
The names the statements of a body bind for the whole function beyond their own lexical
declarations: every `var` outside nested functions and classes, and every function declared in
a nested block whose name Annex B copies into the function *home* (`annex_b_var_home`). A list
of statements not yet standing in the function it becomes the body of is given no such copy.
"""
names: set[str] = set()
queue: deque[Node] = deque(statements)
while queue:
node = queue.popleft()
if isinstance(node, JsFunctionDeclaration):
if (
home is not None
and node.id is not None
and not is_generator_function(node)
and not is_async_function(node)
and annex_b_var_home(node) is home
):
names.add(node.id.name)
continue
if isinstance(node, (*FUNCTION_NODES, JsClassDeclaration, JsClassExpression)):
continue
if isinstance(node, JsVariableDeclaration) and node.kind is JsVarKind.VAR:
for declarator in node.declarations:
if isinstance(declarator, JsVariableDeclarator):
_collect_binding_names(declarator.id, names)
queue.extend(node.children())
return names
def _function_bound_names(func: JsFunctionNode) -> set[str]:
"""
The names *func* binds for the code of its body: its parameter names (`_parameter_names`), the
`let`, `const`, class and function declarations of the body itself, and what the body hoists
(`_hoisted_names`).
"""
names = _parameter_names(func)
body = func.body
if not isinstance(body, JsBlockStatement):
return names
return names | _block_bound_names(body.body) | _hoisted_names(body.body, func)
def _block_bound_names(statements: list[Statement]) -> set[str]:
"""
The names a statement list binds for its own block: its `let`, `const`, class and function
declarations.
"""
names = set(lexically_declared_names(statements))
for statement in statements:
if isinstance(statement, JsFunctionDeclaration) and statement.id is not None:
names.add(statement.id.name)
return names
def _names_bound_by(node: Node) -> set[str]:
"""
The names *node* binds for the code of all its children, beyond what the code around it binds:
a catch parameter, a block's declarations, the `let` or `const` head of a loop, and the name of
a class inside the class.
"""
names: set[str] = set()
if isinstance(node, JsCatchClause):
_collect_binding_names(node.param, names)
elif isinstance(node, JsBlockStatement):
names = _block_bound_names(node.body)
elif isinstance(node, (JsForStatement, JsForInStatement, JsForOfStatement)):
head = node.init if isinstance(node, JsForStatement) else node.left
if isinstance(head, JsVariableDeclaration) and head.kind is not JsVarKind.VAR:
for declarator in head.declarations:
if isinstance(declarator, JsVariableDeclarator):
_collect_binding_names(declarator.id, names)
elif isinstance(node, (JsClassDeclaration, JsClassExpression)) and node.id is not None:
names.add(node.id.name)
return names
def _scoped_children(node: Node) -> Iterator[tuple[Node, set[str]]]:
"""
Each child of *node* together with the names *node* binds for that child. A function binds its
parameter names for its parameter list (`_parameter_names`) and everything it declares for its
body (`_function_bound_names`). The declarations of a `switch` bind for its cases, not for the
discriminant evaluated before the block is entered.
"""
if isinstance(node, FUNCTION_NODES):
head = _parameter_names(node)
whole = _function_bound_names(node)
for child in node.children():
yield child, whole if child is node.body else head
return
if isinstance(node, JsSwitchStatement):
names = _block_bound_names([
statement for case in node.cases if isinstance(case, JsSwitchCase)
for statement in case.body
])
for child in node.children():
yield child, names if isinstance(child, JsSwitchCase) else set()
return
names = _names_bound_by(node)
for child in node.children():
yield child, names
def _walk_scoped(
node: Node,
watched: frozenset[str],
shadowed: frozenset[str] = frozenset(),
) -> Iterator[tuple[Node, frozenset[str]]]:
"""
Every node under *node*, *node* included, in source order, with the names of *watched* that
something between *node* and it binds anew: where a name is in that set, the name no longer
refers to what it refers to at *node*.
"""
stack: list[tuple[Node, frozenset[str]]] = [(node, shadowed)]
while stack:
current, hidden = stack.pop()
yield current, hidden
stack.extend(
(child, hidden | (watched & bound) if bound else hidden)
for child, bound in reversed(list(_scoped_children(current)))
)
def _references_to(stmts: list[Statement], names: frozenset[str]) -> Iterator[JsIdentifier]:
"""
Every identifier in *stmts* that reads or writes one of *names* as the body *stmts* becomes
binds it: a name the statements declare for that body themselves (`_declared_names_in_stmts`)
is theirs, and any other one is bound by the code around them.
"""
own = names & frozenset(_declared_names_in_stmts(stmts))
for stmt in stmts:
for node, shadowed in _walk_scoped(stmt, names, own):
if (
isinstance(node, JsIdentifier)
and node.name in names
and node.name not in shadowed
and is_reference(node)
):
yield node
def _substitute_in_scope(node: Node, env: _StateEnv) -> None:
"""
Replace state variable identifiers with numeric literals. A nested function is not entered: it
reads the state variable when it is called, not at the state being recovered.
"""
for child, bound in _scoped_children(node):
if isinstance(child, FUNCTION_NODES):
continue
if isinstance(child, JsIdentifier) and child.name in env and child.name not in bound:
literal = make_numeric_literal(env[child.name])
if literal is not None:
substitute_use_position(child, literal)
elif bound.isdisjoint(env):
_substitute_in_scope(child, env)
else:
_substitute_in_scope(child, {k: v for k, v in env.items() if k not in bound})
def _strip_scope_param_prefix(
stmts: list[Statement],
scope_param_name: str | None,
) -> list[Statement]:
"""
Rewrite every `scope.X` member access to the bare identifier `X`, dissolving the scope-parameter
prefix unconditionally. Where a name actually lives is decided afterwards by home-driven
qualification, which is independent of the momentary `with`-redirect state, so the strip carries
no namespace and never depends on the traversal's redirect target.
"""
if scope_param_name is None:
return stmts
for stmt in stmts:
_strip_scope_prefix_walk(stmt, scope_param_name)
return stmts
def _scope_members(node: Node, scope_param_name: str) -> list[JsMemberExpression]:
"""
The direct scope members `scope.X` under *node* whose `scope` is the generator's scope
parameter and whose key a declaration may bind as a name (`is_valid_identifier`), leaving out
those below a construct that binds the scope parameter's name anew. A member under any other key
keeps its `scope`, which `_leaves_the_generator` then refuses.
"""
return [
member for member, shadowed in _walk_scoped(node, frozenset({scope_param_name}))
if not shadowed
and isinstance(member, JsMemberExpression)
and _is_direct_scope_member(member, scope_param_name)
and (name := _deepest_property_name(member)) is not None
and is_valid_identifier(name)
]
def _strip_scope_prefix_walk(node: Node, scope_param_name: str) -> None:
for member in _scope_members(node, scope_param_name):
name = _deepest_property_name(member)
assert name is not None
_replace_in_parent(member, JsIdentifier(name=name))
def _qualify_exempt(match: _GeneratorCFFMatch) -> set[str]:
"""
The set of names that home-driven qualification must leave bare: the state variables, the
JavaScript built-in globals, every namespace object, and the compiler-introduced scaffolding
identifiers (generator, scope parameter, argument holder, return flag, redirect variable).
"""
exempt: set[str] = set(match.state_var_names) | _JS_BUILTIN_GLOBALS | set(match.namespaces)
for name in (
match.generator_name,
match.scope_param_name,
match.arg_var_name,
match.did_return_var,
match.with_redirect_var,
):
if name is not None:
exempt.add(name)
return exempt
def _qualify_condition(
condition: Expression,
state: _StateEnv,
match: _GeneratorCFFMatch,
) -> Expression:
"""
Clone, substitute, strip, and qualify a transition condition expression using the same
pipeline as block payloads. Wraps in a synthetic statement so that root-node scope members
and identifiers are processed correctly.
A `scope.X` a condition reads is recorded in `scope_prop_names` before the strip, exactly as
the payload and branch-prefix paths record theirs. A scope member read only in a condition —
`if (scope.x)` on the empty default scope — is `undefined`, and the strip that turns it into a
bare `x` would leave an unbound free read were the name not then declared as a local reading
`undefined`; recording it here is what lets `_declare_recovered_scope_vars` declare it.
"""
wrapper = JsExpressionStatement(expression=_clone_node(condition))
_substitute_in_scope(wrapper, state)
if match.scope_param_name:
_collect_scope_props([wrapper], match)
_strip_scope_prefix_walk(wrapper, match.scope_param_name)
if match.qualifies_namespaces:
_qualify_bare_walk(wrapper, match.namespace_homes, _qualify_exempt(match))
return wrapper.expression # type: ignore[return-value]
_JS_BUILTIN_GLOBALS: frozenset[str] = frozenset({
'globalThis',
'global',
'self',
'window',
'undefined',
'NaN',
'Infinity',
'eval',
'isNaN',
'isFinite',
'parseInt',
'parseFloat',
'decodeURI',
'decodeURIComponent',
'encodeURI',
'encodeURIComponent',
'Object',
'Function',
'Boolean',
'Symbol',
'Number',
'BigInt',
'Math',
'Date',
'String',
'RegExp',
'Array',
'Map',
'Set',
'WeakMap',
'WeakSet',
'ArrayBuffer',
'SharedArrayBuffer',
'DataView',
'JSON',
'Promise',
'Reflect',
'Proxy',
'Error',
'TypeError',
'RangeError',
'ReferenceError',
'SyntaxError',
'URIError',
'EvalError',
'console',
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
'require',
'module',
'exports',
'process',
'Buffer',
'URL',
'URLSearchParams',
'Intl',
'Atomics',
'WebAssembly',
})
def _qualify_with_identifiers(
stmts: list[Statement],
match: _GeneratorCFFMatch,
) -> list[Statement]:
"""
Qualify each bare identifier that names a proven namespace-local by prepending its canonical
home namespace: a bare `x` whose home is `H` becomes `H.x`. The home is fixed at match time and
is independent of the momentary `with`-redirect, so a slot referenced qualified in one position
and bare in another canonicalizes to the same `H.x` in both. Only applies under the
with-redirect pattern with exactly one scope default.
"""
if not match.qualifies_namespaces:
return stmts
exempt = _qualify_exempt(match)
_convert_function_declarations(
stmts,
match.namespace_homes,
exempt | _hoisted_names(stmts, None),
)
for stmt in stmts:
_qualify_bare_walk(stmt, match.namespace_homes, exempt)
return stmts
def _convert_function_declarations(
stmts: list[Statement],
homes: dict[str, tuple[str, ...]],
exempt: set[str],
owner: Node | None = None,
) -> None:
"""
Convert a function declaration whose name is a proven namespace-local into a namespace property
assignment, so every reference to the function goes through its canonical home: a
`function foo(...)` with home `H` becomes `H.foo = function(...)`. A name with no home is left
as a free declaration. Recurses into block bodies but not function bodies.
"""
for i, stmt in enumerate(stmts):
if isinstance(stmt, JsFunctionDeclaration):
if stmt.id is not None and stmt.id.name in homes and stmt.id.name not in exempt:
name = stmt.id.name
func_expr = JsFunctionExpression(
id=None,
params=stmt.params,
body=stmt.body,
generator=is_generator_function(stmt),
is_async=is_async_function(stmt),
)
target = _make_namespace_node([*homes[name], name])
assignment = JsAssignmentExpression(operator='=', left=target, right=func_expr)
stmts[i] = JsExpressionStatement(expression=assignment)
if owner is not None:
stmts[i].parent = owner
continue
if isinstance(stmt, (JsFunctionExpression, JsBlockStatement)):
continue
for child in stmt.children():
if isinstance(child, JsBlockStatement):
_convert_function_declarations(child.body, homes, exempt, owner=child)
def _make_namespace_node(ns_path: list[str]) -> Expression:
"""
Build an AST node for a namespace path: single identifier for length 1,
nested member expressions for longer paths.
"""
node: Expression = JsIdentifier(name=ns_path[0])
for segment in ns_path[1:]:
node = JsMemberExpression(
object=node,
property=JsIdentifier(name=segment),
computed=False,
)
return node
def _qualify_bare_walk(
node: Node,
homes: dict[str, tuple[str, ...]],
exempt: set[str],
shadowed: frozenset[str] = frozenset(),
) -> None:
"""
Qualify the bare names under *node* that have a home. A name something between *node* and the
identifier binds anew (*shadowed*) is left alone there, and a name the payload declares for the
generator itself joins *exempt* from its declaration on, which is shared by the whole payload.
"""
for child, bound in _scoped_children(node):
hidden = shadowed | bound if bound else shadowed
if (
isinstance(child, JsIdentifier)
and child.name in homes
and child.name not in exempt
and child.name not in hidden
):
parent = child.parent
if isinstance(parent, (JsVariableDeclarator, JsRestElement)):
exempt.add(child.name)
continue
if isinstance(parent, (JsFunctionDeclaration, JsClassDeclaration)):
if parent.id is child:
continue
if isinstance(parent, (JsLabeledStatement, JsContinueStatement, JsBreakStatement)):
if getattr(parent, 'label', None) is child:
continue
substitute_use_position(
child,
_make_namespace_node([*homes[child.name], child.name]),
as_spelled=True,
)
continue
_qualify_bare_walk(child, homes, exempt, hidden)
def _collect_binding_names(pattern: Expression | None, out: set[str]) -> None:
"""
Recursively extract bound identifier names from a binding pattern (simple identifier,
array pattern, object pattern, rest element, or assignment pattern with default).
"""
if pattern is None:
return
if isinstance(pattern, JsIdentifier):
out.add(pattern.name)
elif isinstance(pattern, JsRestElement):
_collect_binding_names(pattern.argument, out)
elif isinstance(pattern, JsAssignmentPattern):
_collect_binding_names(pattern.left, out)
elif isinstance(pattern, JsArrayPattern):
for el in pattern.elements:
_collect_binding_names(el, out)
elif isinstance(pattern, JsObjectPattern):
for prop in pattern.properties:
if isinstance(prop, JsRestElement):
_collect_binding_names(prop.argument, out)
elif isinstance(prop, JsProperty) and prop.value is not None:
_collect_binding_names(prop.value, out)
def _is_did_return_assignment(expr: Expression, did_return_var: str | None) -> bool:
"""
Check whether an expression is `didReturnVar = true`.
"""
if did_return_var is None:
return False
if not isinstance(expr, JsAssignmentExpression):
return False
if not isinstance(expr.left, JsIdentifier):
return False
return expr.left.name == did_return_var and expr.operator == '='
def _recover_returns(stmts: list[Statement], did_return_var: str | None) -> list[Statement]:
"""
Rewrite each return that raises the flag,
return didReturn = true, value;
to the return of its value. Recurses into if/else branches and while bodies; a flagged return
anywhere else keeps its flag, which `_leaves_the_generator` then refuses.
"""
if did_return_var is None:
return stmts
result: list[Statement] = []
for stmt in stmts:
if isinstance(stmt, JsReturnStatement) and stmt.argument is not None:
arg = stmt.argument
if isinstance(arg, JsSequenceExpression) and len(arg.expressions) >= 2:
if _is_did_return_assignment(arg.expressions[0], did_return_var):
ret_val = (
arg.expressions[1] if len(arg.expressions) == 2
else JsSequenceExpression(expressions=arg.expressions[1:])
)
result.append(JsReturnStatement(argument=ret_val))
continue
result.append(stmt)
continue
if isinstance(stmt, JsIfStatement):
if stmt.consequent is not None and isinstance(stmt.consequent, JsBlockStatement):
set_child_list(stmt.consequent, 'body', _recover_returns(
stmt.consequent.body, did_return_var
))
if stmt.alternate is not None and isinstance(stmt.alternate, JsBlockStatement):
set_child_list(stmt.alternate, 'body', _recover_returns(
stmt.alternate.body, did_return_var
))
elif isinstance(stmt.alternate, JsIfStatement):
recovered = _recover_returns([stmt.alternate], did_return_var)
if recovered:
set_child(stmt, 'alternate', recovered[0])
elif isinstance(stmt, JsWhileStatement):
if stmt.body is not None and isinstance(stmt.body, JsBlockStatement):
set_child_list(stmt.body, 'body', _recover_returns(stmt.body.body, did_return_var))
result.append(stmt)
return result
def _is_direct_scope_member(node: Node, scope_param_name: str) -> bool:
"""
Check if an expression is a depth-1 member access on the scope parameter, i.e. `scope.X` or
`scope["X"]` but NOT `scope.X.Y`. Only direct slots are CFF routing state; deeper chains are
semantic writes.
"""
if not isinstance(node, JsMemberExpression):
return False
if not isinstance(node.object, JsIdentifier) or node.object.name != scope_param_name:
return False
if node.computed:
return isinstance(node.property, JsStringLiteral)
return True
def _deepest_property_name(node: Node) -> str | None:
"""
Walk a member-expression chain and return the deepest (rightmost) property name.
"""
if isinstance(node, JsIdentifier):
return node.name
if isinstance(node, JsMemberExpression):
if isinstance(node.property, JsIdentifier):
return node.property.name
if isinstance(node.property, JsStringLiteral):
return node.property.value
return None
def _is_bare_redirect_assignment(expr: Expression, redirect_var: str) -> bool:
"""
Whether *expr* is a stripped redirect-routing write `redirect_var = <namespace>` on the bare
redirect identifier. Once the scope prefix is dissolved unconditionally, `scope.redirect_var =
scope.X` survives as this bare assignment of one identifier to another; it is pure routing
bookkeeping with no consumer in the recovered code. The right-hand side must be a bare
identifier — the only shape a stripped `scope.X` target can take — so that a coincidental
side-effecting write to a same-named variable is never discarded.
"""
return (
isinstance(expr, JsAssignmentExpression)
and expr.operator == '='
and isinstance(expr.left, JsIdentifier)
and expr.left.name == redirect_var
and isinstance(expr.right, JsIdentifier)
)
def _filter_redirect_var_assignments(
stmts: list[Statement],
match: _GeneratorCFFMatch,
) -> list[Statement]:
if not match.qualifies_namespaces:
return stmts
redirect_var = match.with_redirect_var
assert redirect_var is not None
return _rewrite_comma_parts(
stmts, lambda part: None if _is_bare_redirect_assignment(part, redirect_var) else part
)
def _rewrite_comma_parts(
stmts: list[Statement],
rewrite: Callable[[Expression], Expression | None],
) -> list[Statement]:
"""
Pass the expression of each expression statement among *stmts*, or each expression of the
comma sequence it holds, through *rewrite*, which gives it back, gives an expression to take
its place, or gives `None` to drop it. A statement none of whose expressions remain goes.
"""
result: list[Statement] = []
for stmt in stmts:
if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
result.append(stmt)
continue
expr = stmt.expression
parts = expr.expressions if isinstance(expr, JsSequenceExpression) else [expr]
remaining = [kept for part in parts if (kept := rewrite(part)) is not None]
if remaining == parts:
result.append(stmt)
elif len(remaining) == 1:
result.append(JsExpressionStatement(expression=remaining[0]))
elif remaining:
result.append(JsExpressionStatement(
expression=JsSequenceExpression(expressions=remaining),
))
return result
def _execute_machine(
machine: _StateMachine,
match: _GeneratorCFFMatch,
slots: _Slots,
main: bool,
) -> tuple[list[Statement], _Slots] | None:
"""
Recover structured code for a run of the machine whose routing slots start out holding *slots*,
using CFG-based structural analysis: builds a control flow graph, identifies loops via dominator
analysis, and emits structured control flow (while, if/else, break). Returns the code and what
the routing slots hold once the run has entered the machine (`_build_cfg`), for the *main* run
or a wrapper's.
"""
entry = _Env(_apply_initial_state(match.state_var_names, match.initial_state), slots)
cfg_result = _build_cfg(machine, entry, match, main)
if cfg_result is None:
return None
cfg, settled = cfg_result
idom = _compute_idom(cfg)
loops = _find_loops(cfg, idom)
stmts = _structural_analysis(cfg, idom, loops)
recovered = _recover_returns(stmts, match.did_return_var)
return (recovered, settled)
def _build_js_if(
condition: Expression,
true_body: list[Statement],
false_body: list[Statement],
) -> Statement:
"""
Build a JsIfStatement, omitting empty branches. Where both are empty, the condition is still
evaluated, as a statement of its own: it may do something, as a call does.
"""
if not true_body and not false_body:
return JsExpressionStatement(expression=condition)
if not true_body:
neg = JsUnaryExpression(operator='!', operand=condition, prefix=True)
return JsIfStatement(
test=neg,
consequent=JsBlockStatement(body=false_body),
)
if not false_body:
return JsIfStatement(
test=condition,
consequent=JsBlockStatement(body=true_body),
)
return JsIfStatement(
test=condition,
consequent=JsBlockStatement(body=true_body),
alternate=JsBlockStatement(body=false_body),
)
@dataclass
class _Activation:
"""
A run of the generator that the recovery turns into the body of one function: the run of the
main call, recovered in place of the call, or the run of a wrapper's call, recovered as the
wrapper's body. A run owns a scope object, and whatever that object holds lives as long as the
object does: the namespaces it is created with and the variables a run stores on it are declared
in the function whose call creates it. A wrapper that hands on the scope parameter itself
shares the object of the run that created the wrapper, and so shares its activation.
"""
function: JsFunctionExpression | None
namespaces: dict[str, Expression]
inherited: set[str] = field(default_factory=set)
props: set[str] = field(default_factory=set)
routing: _Slots = field(default_factory=dict)
def holds(self, name: str) -> bool:
return name in self.namespaces or name in self.inherited or name in self.props
def _creating_activation(
node: Node,
activations: dict[int, _Activation],
main: _Activation,
) -> _Activation:
"""
The activation of the run that creates the wrapper *node*: that of the innermost resolved
wrapper whose recovered body holds it, or the main one. A node in the parameters of a resolved
wrapper is evaluated when that wrapper is called, in the scope of the run that created it.
"""
cursor: Node = node
while (parent := cursor.parent) is not None:
if (
isinstance(parent, JsFunctionExpression)
and cursor is parent.body
and (activation := activations.get(id(parent))) is not None
):
return activation
cursor = parent
return main
def _wrapper_activation(
node: JsFunctionExpression,
scope_arg: Expression,
creator: _Activation,
match: _GeneratorCFFMatch,
) -> _Activation | None:
"""
The activation of the run a wrapper's call starts, read off the scope argument it hands the
generator, in the recovered code the creating run's payload became. The scope parameter itself
shares *creator*'s scope object. An object literal is a fresh scope object per call: each
property holding an inert object literal (`_is_inert`) that reads no name is a namespace created
with it, and a property `scope.K` under its own key `K`, which the strip of the creating run's
payload left as a bare `K`, hands on the creating run's `K`, which that run has to hold and
nothing between its code and the literal may bind anew. A namespace is declared in the
wrapper's body only once the recovery has renamed the wrapper's parameters, so a name its value
read could no longer mean what it meant. Where something between the generator and the scope
argument binds the scope parameter's name anew, the argument is not the creator's scope object.
Any other scope argument gives `None`.
"""
if isinstance(scope_arg, JsIdentifier) and scope_arg.name == match.scope_param_name:
return None if _bound_on_the_way_to(scope_arg, scope_arg.name) else creator
if not isinstance(scope_arg, JsObjectExpression):
return None
if (properties := _scope_object_properties(scope_arg)) is None:
return None
activation = _Activation(node, {})
for key, value in properties.items():
if (
isinstance(value, JsObjectExpression)
and _is_inert(value)
and not _reads_a_name(value)
):
activation.namespaces[key] = value
elif (
isinstance(value, JsIdentifier)
and value.name == key
and creator.holds(key)
and not _bound_on_the_way_to(value, key)
):
activation.inherited.add(key)
else:
return None
return activation
def _reads_a_name(node: Node) -> bool:
"""
Whether evaluating *node* reads a name anywhere below it.
"""
return any(isinstance(part, JsIdentifier) and is_reference(part) for part in node.walk())
def _bound_on_the_way_to(node: Node, name: str) -> bool:
"""
Whether something between the top of the tree that holds *node* and *node* binds *name* anew
(`_scoped_children`).
"""
cursor = node
while (parent := cursor.parent) is not None:
for child, bound in _scoped_children(parent):
if child is cursor:
if name in bound:
return True
break
cursor = parent
return False
def _resolve_shared_wrappers(
stmts: list[Statement],
machine: _StateMachine,
match: _GeneratorCFFMatch,
main: _Activation,
absent: Callable[[str], bool],
) -> list[_Activation] | None:
"""
Walk recovered statements looking for function expressions that are wrappers around the same
shared generator. For each wrapper found, execute the state machine from its entry point and
replace the wrapper with a proper function containing the recovered body. The run of a wrapper
reads the routing slots of the scope object it hands the generator (`_initial_slots`, with
*absent* for keys no literal holds): where it hands on the scope object or namespaces of the
run that creates it, they hold what that run filled them with before any wrapper existed.
Iterates until no more wrappers are resolved (handles nesting).
Returns the activations of the resolved wrappers, each one once and *main* among them where a
wrapper shares its scope object, or `None` where a wrapper cannot be resolved: it would be left
calling the generator the recovery removes. So is a wrapper nested in the recovered body of one
that enters the machine at the same state, whose resolution would recover the same wrapper again
without end, and a key a wrapper hands on from its creator (`_wrapper_activation`) that any run
stores to (`_stores_to_any`): the wrapper's scope object holds a copy of the creator's value
made when it is called, which the recovered code can only share with the creator.
"""
gen_name = match.generator_name
num_vars = len(match.state_var_names)
activations: dict[int, _Activation] = {}
entries: dict[int, list[int | float]] = {}
while True:
resolved_any = False
for node in list(_walk_all(stmts)):
if not isinstance(node, JsFunctionExpression):
continue
node_id = id(node)
if node_id in activations:
continue
wrapper_info = _detect_wrapper_function(node, gen_name, num_vars)
if wrapper_info is None or node.body is None:
continue
if _reenters(node, wrapper_info.initial_state, entries):
return None
creator = _creating_activation(node, activations, main)
activation = _wrapper_activation(node, wrapper_info.scope_arg, creator, match)
if activation is None:
return None
if activation is not creator:
activation.routing = _initial_slots(
machine.routing,
match.scope_param_name,
activation.namespaces,
activation.inherited,
creator.routing,
absent,
)
synthetic = _GeneratorCFFMatch(
generator_name=gen_name,
state_var_names=match.state_var_names,
initial_state=wrapper_info.initial_state,
end_state=match.end_state,
loop_sum=match.loop_sum,
switch_stmt=match.switch_stmt,
switch_label=match.switch_label,
scope_param_name=match.scope_param_name,
arg_var_name=match.arg_var_name,
did_return_var=match.did_return_var,
result_var=None,
gen_decl_index=0,
scaffolding_end=0,
with_redirect_var=match.with_redirect_var,
scope_default_props=match.scope_default_props,
namespaces=match.namespaces,
namespace_homes=match.namespace_homes,
)
result = _execute_machine(machine, synthetic, activation.routing, main=False)
if result is None:
return None
activation.props |= synthetic.scope_prop_names
activations[node_id] = activation
entries[node_id] = wrapper_info.initial_state
recovered, _ = result
target = _wrapper_arg_param_name(match, wrapper_info, recovered)
if match.arg_var_name and target and match.arg_var_name != target:
recovered = _rebind_free_arg_var(recovered, match.arg_var_name, target)
recovered = keeping_directives(node.body, recovered)
set_child(node, 'body', JsBlockStatement(body=recovered))
if target is not None and target != wrapper_info.rest_param_name:
set_child_list(node, 'params', _rebind_wrapper_param(node.params, target))
if activation is not creator:
_unpack_argument_stores(node, activation)
resolved_any = True
if not resolved_any:
break
resolved = list({id(activation): activation for activation in activations.values()}.values())
inherited = frozenset(name for activation in resolved for name in activation.inherited)
if inherited and _stores_to_any(stmts, inherited):
return None
return resolved
def _reenters(
node: JsFunctionExpression,
initial_state: list[int | float],
entries: dict[int, list[int | float]],
) -> bool:
"""
Whether a resolved wrapper whose recovered body holds the wrapper *node* enters the machine at
the same *initial_state*, as recorded in *entries* by the node ids of the resolved wrappers.
"""
cursor = node.parent
while cursor is not None:
if entries.get(id(cursor)) == initial_state:
return True
cursor = cursor.parent
return False
def _stores_to_any(stmts: list[Statement], names: frozenset[str]) -> bool:
"""
Whether *stmts* store to one of *names* where nothing between the statement and the store binds
the name anew.
"""
return any(
isinstance(node, JsIdentifier)
and node.name in names
and node.name not in shadowed
and is_reference(node)
and is_member_write_target(node)
for stmt in stmts
for node, shadowed in _walk_scoped(stmt, names)
)
def _is_activation_slot(target: Node | None, activation: _Activation) -> bool:
"""
Whether *target* stores to what the scope object of *activation* holds for its own run: a
member of a namespace the object is created with, or a variable the run keeps on the object.
"""
if isinstance(target, JsIdentifier):
return target.name in activation.props and target.name not in activation.inherited
return (
isinstance(target, JsMemberExpression)
and isinstance(target.object, JsIdentifier)
and target.object.name in activation.namespaces
and access_key(target) is not None
)
def _argument_slots(
pattern: JsArrayExpression | JsArrayPattern,
activation: _Activation,
) -> tuple[list[Expression], Expression | None] | None:
"""
The slots an argument store `[s1, …, sn] = rest` or `[s1, …, ...r] = rest` fills, in order,
and the slot taking the remaining arguments where the pattern ends in a rest element. `None`
where one of them is not a slot of *activation*'s own scope object (`_is_activation_slot`), or
the pattern skips an element or gives one a default.
"""
elements = list(pattern.elements)
remainder: Expression | None = None
if elements and isinstance(last := elements[-1], (JsRestElement, JsSpreadElement)):
remainder = last.argument
elements.pop()
if not _is_activation_slot(remainder, activation):
return None
slots: list[Expression] = []
for element in elements:
if element is None or not _is_activation_slot(element, activation):
return None
slots.append(element)
return slots, remainder
def _unpack_argument_stores(function: JsFunctionExpression, activation: _Activation) -> None:
"""
Give a wrapper whose run owns its scope object the parameters its run stores its arguments
into. A run starts by destructuring the argument holder into slots of its own scope object, as
in `[NS.a, NS.b] = rest` for the wrapper `function (...rest)`, and a function whose own
parameters ended in a rest parameter is stored as `[NS.a, ...NS.r] = rest`. Where the rest
parameter is read by such stores and nothing else, the wrapper takes the parameters `(a, ...r)`
and the store becomes `NS.a = a, NS.r = r`: every slot receives what it received, the remainder
being an array of the same arguments. Every store has to be a statement of the body itself,
which runs once per call: the destructuring makes a new array each time it runs, and the rest
parameter is one array per call. The wrapper's `length` counts the new parameters, as the
function the obfuscator flattened counted them, and the destructuring's array iterator is
trusted the way the recovery trusts the rest of the obfuscator's scaffolding. A body that reads
its own `arguments`, whose elements a simple parameter list aliases, is left alone.
"""
params = function.params
if (
function.body is None
or len(params) != 1
or not isinstance(rest := params[0], JsRestElement)
or not isinstance(rest.argument, JsIdentifier)
or references_own_arguments(function)
):
return
stores: list[tuple[JsAssignmentExpression, list[Expression], Expression | None]] = []
for read in _references_to(function.body.body, frozenset({rest.argument.name})):
store = read.parent
if not (
isinstance(store, JsAssignmentExpression)
and store.operator == '='
and store.right is read
and isinstance(statement := store.parent, JsExpressionStatement)
and statement.parent is function.body
and isinstance(pattern := store.left, (JsArrayExpression, JsArrayPattern))
and (slots := _argument_slots(pattern, activation)) is not None
):
return
stores.append((store, *slots))
if not stores or len(stores) > 1 and any(remainder is not None for _, _, remainder in stores):
return
taken = {node.name for node in function.walk() if isinstance(node, JsIdentifier)}
def fresh(slot: Expression) -> str:
base = _deepest_property_name(slot)
name = fresh_name(base if base and is_valid_identifier(base) else 'p', taken.__contains__)
taken.add(name)
return name
names: list[str] = []
for index in range(max(len(elements) for _, elements, _ in stores)):
slot = next(elements[index] for _, elements, _ in stores if index < len(elements))
names.append(fresh(slot))
remainder_name: str | None = None
for store, elements, remainder in stores:
assignments: list[Expression] = [
JsAssignmentExpression(operator='=', left=element, right=JsIdentifier(name=name))
for element, name in zip(elements, names)
]
if remainder is not None:
remainder_name = fresh(remainder)
assignments.append(JsAssignmentExpression(
operator='=', left=remainder, right=JsIdentifier(name=remainder_name)))
if not assignments:
statement = store.parent
assert statement is not None
_remove_from_parent(statement)
elif len(assignments) == 1:
_replace_in_parent(store, assignments[0])
else:
_replace_in_parent(store, JsSequenceExpression(expressions=assignments))
new_params: list[Expression] = [JsIdentifier(name=name) for name in names]
if remainder_name is not None:
new_params.append(JsRestElement(argument=JsIdentifier(name=remainder_name)))
set_child_list(function, 'params', new_params)
def _walk_all(stmts: list[Statement]):
for stmt in stmts:
yield from stmt.walk()
def _rebind_wrapper_param(params: list[Expression], target: str) -> list[Expression]:
"""
Rename the binding of a wrapper's last parameter to *target*, preserving any leading parameters
and whether that last parameter is a rest element or a plain identifier. A rest wrapper
`(...rest)` becomes `(...target)` and a plain wrapper `(p)` becomes `(target)`, so the recovered
body — whose argument-variable references were rebound onto *target* — keeps the wrapper's
original arity and its rest-versus-scalar argument mapping. A parameterless wrapper gains a
single rest parameter.
"""
if not params:
return [JsRestElement(argument=JsIdentifier(name=target))]
result = list(params)
last = result[-1]
if isinstance(last, JsRestElement):
result[-1] = JsRestElement(argument=JsIdentifier(name=target))
else:
result[-1] = JsIdentifier(name=target)
return result
def _wrapper_arg_param_name(
match: _GeneratorCFFMatch,
wrapper_info: _WrapperFunctionInfo,
recovered: list[Statement],
) -> str | None:
"""
Choose the identifier a wrapper's recovered body should use for the shared generator's argument
variable, or `None` when the body never references it in a value position. The wrapper's own
rest-parameter name is preferred, but only when it neither collides with a state-machine
variable nor occurs anywhere in the recovered body: any occurrence there — a nested binding of
the name or a free reference to an outer one — would be captured once the argument variable is
rebound onto it. When the rest-param is unusable (or absent), a fresh identifier not present in
the recovered body is minted instead.
"""
if match.arg_var_name is None:
return None
taken: set[str] = set()
referenced: set[str] = set()
for node in _walk_all(recovered):
if isinstance(node, JsIdentifier):
taken.add(node.name)
if is_reference(node):
referenced.add(node.name)
if match.arg_var_name not in referenced:
return None
rest = wrapper_info.rest_param_name
if rest is not None and rest not in match.state_var_names and rest not in taken:
return rest
return fresh_name(match.arg_var_name, taken.__contains__)
def _rebind_free_arg_var(
stmts: list[Statement],
arg_var_name: str,
param_name: str,
) -> list[Statement]:
"""
Rename free references to the shared generator's argument variable to a wrapper's parameter
name. A construct that binds either name anew (`_scoped_children`) owns that identifier and is
left untouched; a function binding neither is descended into, so a genuine closure over the
wrapper arguments is still rebound. Member-property and object-key
positions are skipped because a name there is not a variable reference; an object shorthand
`{arg}` is expanded to `{arg: param}` so the value read is rebound without renaming the key.
"""
for stmt in stmts:
_rebind_arg_var_in_scope(stmt, arg_var_name, param_name)
return stmts
def _rebind_arg_var_in_scope(node: Node, arg_var_name: str, param_name: str) -> None:
for child, bound in _scoped_children(node):
if arg_var_name in bound or param_name in bound:
continue
if isinstance(child, JsIdentifier) and child.name == arg_var_name:
substitute_use_position(
child, JsIdentifier(name=param_name, offset=child.offset))
continue
_rebind_arg_var_in_scope(child, arg_var_name, param_name)
def _emit_scope_namespace_declarations(
namespaces: dict[str, Expression],
recovered: list[Statement],
) -> list[Statement]:
"""
Emit `var X = …` for each namespace a scope object is created with, but only for namespaces
still referenced in the recovered body. Once free names are left bare, a namespace whose every
member turned out free has no surviving reference, so its declaration would be dead; a surviving
`X.member` keeps it.
"""
referenced = {node.name for node in _walk_all(recovered) if isinstance(node, JsIdentifier)}
declarations: list[Statement] = []
for name, init in namespaces.items():
if name not in referenced:
continue
decl = JsVariableDeclaration(
declarations=[JsVariableDeclarator(
id=JsIdentifier(name=name),
init=_clone_node(init),
)],
kind=JsVarKind.VAR,
)
declarations.append(decl)
return declarations
def _collect_scope_props(stmts: list[Statement], match: _GeneratorCFFMatch) -> None:
"""
Record the property names of depth-1 scope-member accesses (`scope.X` / `scope["X"]`) in
*stmts* in `scope_prop_names`. The names identify which bare identifiers in the recovered code
originated as variables stored on the scope object, so the recovery can declare the live ones
and drop write-only routing slots.
"""
if match.scope_param_name is None:
return
for stmt in stmts:
for member in _scope_members(stmt, match.scope_param_name):
name = _deepest_property_name(member)
assert name is not None
match.scope_prop_names.add(name)
def _collect_read_names(node: Node | None, out: set[str]) -> None:
"""
Collect names of identifiers that are read (appear in a value position) within *node*.
Assignment targets, declaration ids, and non-computed member property names do not count as
reads.
"""
if node is None:
return
if isinstance(node, JsAssignmentExpression):
if isinstance(node.left, JsIdentifier):
if node.operator != '=':
out.add(node.left.name)
else:
_collect_read_names(node.left, out)
if node.right is not None:
_collect_read_names(node.right, out)
return
if isinstance(node, JsVariableDeclarator):
if node.init is not None:
_collect_read_names(node.init, out)
return
if isinstance(node, JsMemberExpression):
_collect_read_names(node.object, out)
if node.computed:
_collect_read_names(node.property, out)
return
if isinstance(node, JsProperty):
if node.computed:
_collect_read_names(node.key, out)
_collect_read_names(node.value, out)
return
if isinstance(node, JsIdentifier):
out.add(node.name)
return
for child in node.children():
_collect_read_names(child, out)
def _is_pure_rhs(node: Node, known: set[str]) -> bool:
"""
Conservative purity check for dead-store removal: the expression contains no call, `new`,
tagged template, assignment, update, `delete`, `yield` or `await`, and reads no name but those
in *known*, which the recovered code declares itself; any other name may be unbound, and reading
it throws. Dropping the statement then cannot discard an observable side effect.
"""
for n in node.walk():
if isinstance(n, (
JsCallExpression,
JsNewExpression,
JsTaggedTemplateExpression,
JsAssignmentExpression,
JsUpdateExpression,
JsYieldExpression,
JsAwaitExpression,
)):
return False
if isinstance(n, JsUnaryExpression) and n.operator == 'delete':
return False
if isinstance(n, JsIdentifier) and n.name not in known and is_reference(n):
return False
return True
def _namespace_keys_read(
stmts: list[Statement],
namespaces: set[str],
names: set[str],
) -> dict[str, set[str] | None]:
"""
For each namespace in *namespaces*, the keys *stmts* read from it where nothing between the
statement and the read binds the namespace's name anew, or `None` where they may read any key:
under a key only known at runtime, or through the namespace itself, which any use but as the
object of an access under a static key hands on whole, as `JSON.stringify(NS)` and `k in NS`
do. Everything but the target of a plain `=` reads the key. The names *stmts* read bare count
as keys read from every namespace, since a `with` statement may have resolved one to a member.
A read through a reference to the namespace that *stmts* do not spell with its name, the
receiver of a method called on it, is not seen: the recovery trusts that the obfuscator never
calls a method of a namespace on the namespace.
"""
if not namespaces:
return {}
watched = frozenset(namespaces)
keys: dict[str, set[str] | None] = {name: set(names) for name in namespaces}
for stmt in stmts:
for node, shadowed in _walk_scoped(stmt, watched):
if (
not isinstance(node, JsIdentifier)
or (name := node.name) not in watched
or name in shadowed
or not is_reference(node)
or (read := keys[name]) is None
):
continue
member = node.parent
if not isinstance(member, JsMemberExpression) or member.object is not node:
keys[name] = None
elif is_simple_assignment_target(member):
continue
elif (key := access_key(member)) is None:
keys[name] = None
else:
read.add(key)
return keys
def _remove_dead_scope_writes(
stmts: list[Statement],
dead: set[str],
keys_read: dict[str, set[str] | None],
known: set[str],
) -> list[Statement]:
"""
Remove the stores the statements *stmts* of a body make at their top level to scope slots
nothing reads: a write-only scope variable in *dead* where the value has no effect
(`_is_pure_rhs`, with the names *known* to be declared), and a number stored in a member of a
namespace under a key *keys_read* does not list for it (`_namespace_keys_read`), as the
obfuscator stores its routing values, whose only readers were the case tests the recovery
removes. A store goes as a whole statement, as one expression of a comma sequence, or as one
pair of target and value in a destructuring of an array literal (`_store_pairs`).
"""
def is_dead(target: Expression, value: Expression) -> bool:
if isinstance(target, JsIdentifier):
return target.name in dead and _is_pure_rhs(value, known)
return (
isinstance(target, JsMemberExpression)
and isinstance(target.object, JsIdentifier)
and (read := keys_read.get(target.object.name)) is not None
and (key := access_key(target)) is not None
and key not in read
and _eval_number(value) is not None
)
def live_part(expr: Expression) -> Expression | None:
if (pairs := _store_pairs(expr)) is None:
return expr
live = [(target, value) for target, value in pairs if not is_dead(target, value)]
if len(live) == len(pairs):
return expr
if not live:
return None
if len(live) == 1 and _is_pure_rhs(live[0][1], known):
target, value = live[0]
return JsAssignmentExpression(operator='=', left=target, right=value)
return JsAssignmentExpression(
operator='=',
left=JsArrayPattern(elements=[target for target, _ in live]),
right=JsArrayExpression(elements=[value for _, value in live]),
)
return _rewrite_comma_parts(stmts, live_part)
def _declared_names_in_stmts(stmts: list[Statement]) -> set[str]:
"""
The names the statement list *stmts* declares for the body it becomes: its own `let`, `const`,
class and function declarations and every `var` outside nested functions and classes
(`_hoisted_names`). A declaration inside a nested function, or a lexical one inside a nested
block, binds nothing for that body.
"""
return _block_bound_names(stmts) | _hoisted_names(stmts, None)
def _declare_recovered_scope_vars(
recovered: list[Statement],
activation: _Activation,
) -> list[Statement]:
"""
Declare what the scope object of *activation* holds at the head of the body *recovered*
recovers for it. Hoisted scope variables survive recovery as bare identifiers: the ones that are
read are declared as locals, and the writes of slots that are pure routing bookkeeping (written
but never read), members of the namespaces the object is created with among them, are dropped
(`_remove_dead_scope_writes`). Slots already declared and the namespaces the object is created
with or hands on are left to their own declarations; an in-body namespace (created by a
`scope.X = {}` write) is a slot like any other, so it is declared here.
"""
props = activation.props
reads: set[str] = set()
for stmt in recovered:
_collect_read_names(stmt, reads)
dead = {p for p in props if p not in reads}
keys_read = _namespace_keys_read(recovered, set(activation.namespaces), reads)
declared = _declared_names_in_stmts(recovered)
known = props | activation.inherited | set(activation.namespaces) | declared
recovered = _remove_dead_scope_writes(recovered, dead, keys_read, known)
present: set[str] = set()
for stmt in recovered:
for node in stmt.walk():
if isinstance(node, JsIdentifier):
present.add(node.name)
exclude = declared | set(activation.namespaces) | activation.inherited
to_declare = sorted(p for p in props if p in present and p not in exclude)
declarations = _emit_scope_namespace_declarations(activation.namespaces, recovered)
if to_declare:
declarations.append(JsVariableDeclaration(
declarations=[
JsVariableDeclarator(id=JsIdentifier(name=name), init=None) for name in to_declare
],
kind=JsVarKind.VAR,
))
return declarations + recovered
def _declare_wrapper_scope_vars(activation: _Activation) -> None:
"""
Declare what the scope object of a wrapper's run holds at the head of the wrapper's recovered
body, behind its directives.
"""
function = activation.function
assert function is not None and function.body is not None
body = function.body.body
split = len(directive_prologue(function.body))
recovered = _declare_recovered_scope_vars(body[split:], activation)
set_child_list(function.body, 'body', body[:split] + recovered)
def _bind_main_arguments(recovered: list[Statement], match: _GeneratorCFFMatch) -> bool:
"""
The main call hands the generator nothing beyond its state, so its run reads the argument
variable as `undefined`: every read of it that the recovered code keeps becomes `void 0`. A
store to it, or a `delete` of it, cannot be expressed that way and gives `False`.
"""
if match.arg_var_name is None:
return True
reads = list(_references_to(recovered, frozenset({match.arg_var_name})))
if any(is_member_write_target(read) for read in reads):
return False
for read in reads:
substitute_use_position(read, make_undefined_expression())
return True
def _settle_returns(
recovered: list[Statement],
match: _GeneratorCFFMatch,
trailing: list[Statement],
wrapped: bool,
) -> list[Statement] | None:
"""
The recovered code with every `return` of the generator doing what it did, or `None` where
that cannot be written. The code replaces the call in the body of a function or a script, so
running off its end runs into the statements behind the call (*trailing*) and nothing else. A
return of the generator only ends the generator, and its value is the result of the call. A
returned call makes every such return a return of the function, and has the function return
`undefined` where the generator ends without one, which the recovered code running on into
*trailing* would not. The guard `if (didReturn) { return result; }` makes a return that raises
the flag a return of the function and lets any other one fall through to what follows, which
only a return at the end of the recovered code could be written as. A wrapper's run raises the
same flag when it returns, so where the call has *wrapped* one and *trailing* is not empty, a
wrapper called during the call would make the guard return where the recovered code runs on.
Without either, every return falls through, and `sanitize_inlined_body` writes the one at the
end as the expression it returns.
"""
plain = False
for node in walk_scope(match.switch_stmt):
if not isinstance(node, JsReturnStatement):
continue
argument = node.argument
if not (
isinstance(argument, JsSequenceExpression)
and argument.expressions
and _is_did_return_assignment(argument.expressions[0], match.did_return_var)
):
plain = True
if match.returns_value:
return None if trailing else recovered
if match.guarded:
return None if plain or (wrapped and trailing) else recovered
return sanitize_inlined_body(recovered)
def _scaffolding_is_private(
model: SemanticModel,
body: list[Statement],
match: _GeneratorCFFMatch,
) -> bool:
"""
Whether nothing but the scaffolding the recovery removes can reach a binding it removes: the
generator, and the flag and the result of its call (`_reached_only_from`).
"""
removed = body[match.gen_decl_index:match.scaffolding_end + 1]
inside = {id(node) for statement in removed for node in statement.walk()}
generator = removed[0]
assert isinstance(generator, JsFunctionDeclaration) and generator.id is not None
names = {match.did_return_var, match.result_var}
anchors = [generator.id]
for statement in removed[1:]:
anchors.extend(
node for node in statement.walk()
if isinstance(node, JsIdentifier) and node.name in names
)
for anchor in anchors:
binding = model.binding_of(anchor) or model.resolve(anchor)
if binding is None or not _reached_only_from(model, binding, inside):
return False
return True
def _reached_only_from(model: SemanticModel, binding: Binding, inside: set[int]) -> bool:
"""
Whether every way the program can reach *binding* stands among the nodes whose ids are
*inside*: its references, those a `with` body resolves, the opaque reflective surfaces that
could name it (`refinery.lib.scripts.js.analysis.model.SemanticModel.reflection_surface_sites`),
and every declaration that stores a value in it (`_declaration_stores`).
"""
return (
all(id(node) in inside for node in model.references(binding))
and all(id(node) in inside for node in binding.dynamic_refs)
and all(id(node) in inside for node in model.reflection_surface_sites(binding))
and all(
id(node) in inside or not _declaration_stores(node) for node in binding.declarations
)
)
def _declaration_stores(identifier: Node) -> bool:
"""
Whether the declaration that *identifier* names puts a value in its binding, which all but a
declarator without an initializer do.
"""
declarator = identifier.parent
return not (
isinstance(declarator, JsVariableDeclarator)
and declarator.id is identifier
and declarator.init is None
)
def _declares_only_its_own_names(
model: SemanticModel,
body: list[Statement],
match: _GeneratorCFFMatch,
recovered: list[Statement],
) -> bool:
"""
Whether no code of the function around the call means anything else by a name the recovered
code declares for that function (`_declared_names_in_stmts`). The recovery makes the
generator's locals and the slots of its scope object locals of that function, where they would
otherwise capture a name the function reads from outside, merge with one it declares, or clash
with a lexical declaration of the same name. Every occurrence of such a name in the parameters
and the body of that function, outside the removed statements, has to resolve to a binding
declared below the function's own scope, and the function may not declare the name lexically.
The name a function declaration gives itself stands in the scope around it and is not read.
"""
names = _declared_names_in_stmts(recovered)
if not names:
return True
removed = body[match.gen_decl_index:match.scaffolding_end + 1]
inside = {id(node) for statement in removed for node in statement.walk()}
home = model.scope_of(body[match.scaffolding_end])
if home is None:
return False
for name in names:
binding = model.lookup(name, home)
if binding is not None and binding.scope is home and binding.is_lexical:
return False
owner = home.node
roots: list[Node] = list(body)
if isinstance(owner, FUNCTION_NODES):
roots.extend(owner.params)
for root in roots:
for node in root.walk():
if (
id(node) in inside
or not isinstance(node, JsIdentifier)
or node.name not in names
or not is_use_position(node)
):
continue
binding = model.binding_of(node) or model.resolve(node)
if binding is None or not home.contains(binding.scope, strict=True):
return False
return True
def _leaves_the_generator(recovered: list[Statement], match: _GeneratorCFFMatch) -> bool:
"""
Whether *recovered* still refers to a binding the recovery removes: the generator, its
parameters, or the scaffolding variables of its call. Such a reference would be left to resolve
to whatever the code around it binds under that name, or to nothing.
"""
names = {
match.generator_name,
match.scope_param_name,
match.arg_var_name,
match.did_return_var,
match.result_var,
*match.state_var_names,
}
names.discard(None)
return any(True for _ in _references_to(recovered, frozenset(names)))
def _is_a_function_body(node: Node) -> bool:
"""
Whether *node* holds the statements a script or a function runs rather than a block inside
them. Only there does running off the end of the recovered code mean what the end of the
generator meant, and only there do the slots it declares live as long as one call does.
"""
if isinstance(node, JsScript):
return True
function = node.parent
return (
isinstance(node, JsBlockStatement)
and isinstance(function, FUNCTION_NODES)
and function.body is node
)
def _values_the_recovery_binds(
model: SemanticModel,
roots: Sequence[Node],
match: _GeneratorCFFMatch,
) -> Iterator[Node]:
"""
The values under *roots* that the recovery of *match* hands to bindings where the scaffolding
kept them in properties: what the scope object receives — from the parameter's default, from a
literal a call of the generator passes, and from a store to one of its slots — and what a store
to a name a `with` statement resolves stores. A pattern storing into one of these yields every
node of its right side that may denote a value, since which of them reaches the slot is not
read here.
A store to a member of a namespace the scope object holds is not one of them: the recovery
declares the namespace, not its keys, so the value stays where no binding holds it. A store
the model does not know was made by an earlier recovery of the invocation, which judged it.
"""
for root in roots:
for node in root.walk():
if isinstance(node, JsAssignmentPattern):
target = node.left
if isinstance(target, JsIdentifier) and target.name == match.scope_param_name:
yield from _literal_values(node.right)
elif isinstance(node, JsCallExpression):
if any(
isinstance(leaf, JsIdentifier) and leaf.name == match.generator_name
for leaf in denoting_leaves(node.callee)
):
for argument in node.arguments:
yield from _literal_values(argument)
elif isinstance(node, JsAssignmentExpression) and node.right is not None:
target = strip_parens(node.left)
if isinstance(target, (JsIdentifier, JsMemberExpression)):
if _stores_into_the_scope(model, target, match):
yield node.right
elif _stores_into_the_scope_through(model, target, match):
yield from (
value for value in node.right.walk()
if isinstance(value, (JsIdentifier, JsThisExpression, JsMemberExpression))
)
def _literal_values(node: Node | None) -> Iterator[Node]:
"""
The values of the properties of *node* where it is an object literal.
"""
if isinstance(node, JsObjectExpression):
for prop in node.properties:
if isinstance(prop, JsProperty) and prop.value is not None:
yield prop.value
def _stores_into_the_scope(model: SemanticModel, target: Node, match: _GeneratorCFFMatch) -> bool:
"""
Whether a store to *target* stores into a slot of the scope object of *match*: through its
parameter, or through a name a `with` statement resolves, which no binding below the statement
holds and the model knows to cross it.
"""
if isinstance(target, JsMemberExpression):
holder = strip_parens(target.object)
return isinstance(holder, JsIdentifier) and holder.name == match.scope_param_name
return (
isinstance(target, JsIdentifier)
and model.knows(target)
and model.resolve(target) is None
and model.read_has_dynamic_effect(target)
)
def _stores_into_the_scope_through(
model: SemanticModel,
pattern: Node | None,
match: _GeneratorCFFMatch,
) -> bool:
"""
Whether the pattern *pattern* stores into a slot of the scope object of *match* at one of its
positions (`_stores_into_the_scope`).
"""
if pattern is None:
return False
return any(_stores_into_the_scope(model, position, match) for position in pattern.walk())
def _bindings_past_the_with(model: SemanticModel, roots: Sequence[Node]) -> Iterator[Binding]:
"""
The bindings outside a `with` statement under *roots* that a name the statement may resolve
reaches past it (`refinery.lib.scripts.js.analysis.model.SemanticModel.lookup` across it).
While the statement stands, a write through such a name is attributed to the name alone; the
recovery removes the statement, after which the name resolves to the binding and a write
through it is attributed to what the binding holds. A name the model does not know stands in
code an earlier recovery of the invocation made, which judged it.
"""
for root in roots:
for node in root.walk():
if isinstance(node, JsIdentifier) and model.read_has_dynamic_effect(node):
binding = model.lookup(node.name, model.scope_of(node), cross_dynamic=True)
if binding is not None:
yield binding
def _recovery_reveals(
model: SemanticModel,
scan: IntrinsicWrites,
removed: list[Statement],
recovered: list[Statement],
match: _GeneratorCFFMatch,
) -> bool:
"""
Whether replacing the scaffolding *removed* of *match* with *recovered* shows the models a
write the held ones do not record: a store the recovery turns into a binding store hands the
binding a value the scan follows only through bindings (`_values_the_recovery_binds`), the
removed `with` statement stood between a name and a binding whose values denote a root the scan
watches (`_bindings_past_the_with`), or the recovered code declares or assigns a name the scan
watches (`refinery.lib.scripts.js.deobfuscation.helpers.declares_a_watched_name`).
"""
return (
any(
scan.follows_only_through_bindings(value)
for value in _values_the_recovery_binds(model, removed, match)
)
or any(
scan.denotes_a_watched_root(binding)
for binding in _bindings_past_the_with(model, removed)
)
or declares_a_watched_name(recovered)
)
class JsGeneratorCFFUnflattening(PinnedTransformer, BodyProcessingTransformer):
"""
Recover original code from generator-based state-machine CFF dispatchers. Handles the pattern
where a function body is replaced with a generator function containing a while/switch state
machine driven by multiple state variables.
The pass holds the two models it reads, the semantic model and the intrinsic-write scan, for an
invocation (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`). A recovery
replaces the scaffolding of a machine with code made from copies of its states, and leaves the
answers about the next machine no more permissive than rebuilt ones:
- Whether the scaffolding is private (`_scaffolding_is_private`) is asked of the bindings the
recovery of that machine removes, which are its own: no other recovery declares or reaches
them.
- Whether the recovered code declares only its own names (`_declares_only_its_own_names`) is
asked of the function around the machine outside its scaffolding. Before deciding a machine
whose body, or whose function outside the scaffolding, holds a body the invocation edited
since the last renewal, the pass renews.
- Whether a plain object inherits a routing key (`_provably_absent`) is read off the scan, which
a recovery changes only where it turns a store of a value the scan follows only through
bindings into a binding store, where it removes the `with` statement between a name and a
binding whose values denote a root the scan watches, or where its code declares or assigns a
name the scan watches (`_recovery_reveals`). After such a recovery, the pass renews.
"""
reads = ('_model', '_intrinsic_writes')
def __init__(self):
super().__init__()
self._edited: dict[Node, list[Node]] = {}
def visit_JsScript(self, node: JsScript):
with self.holding_models(node):
return super().visit_JsScript(node)
def _forget_edits(self) -> None:
self._edited = {}
def _note_edit(self, body: Node) -> None:
"""
Record that the invocation edited *body*, under the script and every function that holds it,
which are the places `_edited_around` asks about.
"""
cursor: Node | None = body
while cursor is not None:
if isinstance(cursor, (JsScript, *FUNCTION_NODES)):
self._edited.setdefault(cursor, []).append(body)
cursor = cursor.parent
def _edited_around(self, parent: Node, removed: list[Statement]) -> bool:
"""
Whether a body the invocation edited since the last renewal is *parent*, the body holding a
machine, or lies in the function holding it outside the scaffolding *removed*.
"""
function = parent if isinstance(parent, JsScript) else parent.parent
if function is None:
return False
return any(
body is parent or not any(body.is_descendant_of(statement) for statement in removed)
for body in self._edited.get(function, ())
)
def _provably_absent(self, key: str) -> bool:
"""
Whether a plain object that does not own *key* reads `undefined` under it, and a store there
makes a property of data: no prototype the language installs holds the key, and the program
writes no prototype of a plain object
(`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.chain_roots_unwritten`), or
at least not this key
(`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.key_written`), as a file
patching another key of `Object`, such as a polyfill, does not.
"""
scan = self.held_models().intrinsic_writes
return property_absent_from_written_chain(None, dict, key) and (
scan.chain_roots_unwritten(dict) or not scan.key_written('Object', key)
)
def _process_body(self, parent: Node, body: list[Statement]) -> None:
if not _is_a_function_body(parent):
return
i = 0
while i < len(body):
match = _match_generator_cff(body, i)
if match is None:
i += 1
continue
models = self.held_models()
removed = body[match.gen_decl_index:match.scaffolding_end + 1]
if self._edited_around(parent, removed):
self.renew_models()
model = models.model
if not _scaffolding_is_private(model, body, match):
i += 1
continue
if (
match.routed
and _spells_an_inherited_name_bare(match.switch_stmt, self._provably_absent)
):
i += 1
continue
machine = _extract_state_blocks(match, self._provably_absent)
if machine is None:
i += 1
continue
slots = _initial_slots(
machine.routing,
match.scope_param_name,
match.scope_default_inits,
set(),
{},
self._provably_absent,
)
result = _execute_machine(machine, match, slots, main=True)
if result is None:
i += 1
continue
recovered, settled = result
main = _Activation(
None,
match.scope_default_inits,
props=match.scope_prop_names,
routing=settled,
)
wrappers = _resolve_shared_wrappers(
recovered, machine, match, main, self._provably_absent
)
if wrappers is None or not _bind_main_arguments(recovered, match):
i += 1
continue
for activation in wrappers:
if activation.function is not None:
_declare_wrapper_scope_vars(activation)
recovered = _declare_recovered_scope_vars(recovered, main)
if (
_leaves_the_generator(recovered, match)
or not _declares_only_its_own_names(model, body, match, recovered)
):
i += 1
continue
settled = _settle_returns(
recovered,
match,
body[match.scaffolding_end + 1:],
bool(wrappers),
)
if settled is None:
i += 1
continue
recovered = settled
for s in recovered:
s.parent = parent
start = match.gen_decl_index
end = match.scaffolding_end
reveals = _recovery_reveals(model, models.intrinsic_writes, removed, recovered, match)
replacement = body[:start] + recovered + body[end + 1:]
self._replace_body(parent, replacement)
self._note_edit(parent)
if reveals:
self.renew_models()
i = start + len(recovered)
Classes
class JsGeneratorCFFUnflattening-
Recover original code from generator-based state-machine CFF dispatchers. Handles the pattern where a function body is replaced with a generator function containing a while/switch state machine driven by multiple state variables.
The pass holds the two models it reads, the semantic model and the intrinsic-write scan, for an invocation (
PinnedTransformer). A recovery replaces the scaffolding of a machine with code made from copies of its states, and leaves the answers about the next machine no more permissive than rebuilt ones:- Whether the scaffolding is private (
_scaffolding_is_private) is asked of the bindings the recovery of that machine removes, which are its own: no other recovery declares or reaches them. - Whether the recovered code declares only its own names (
_declares_only_its_own_names) is asked of the function around the machine outside its scaffolding. Before deciding a machine whose body, or whose function outside the scaffolding, holds a body the invocation edited since the last renewal, the pass renews. - Whether a plain object inherits a routing key (
_provably_absent) is read off the scan, which a recovery changes only where it turns a store of a value the scan follows only through bindings into a binding store, where it removes thewithstatement between a name and a binding whose values denote a root the scan watches, or where its code declares or assigns a name the scan watches (_recovery_reveals). After such a recovery, the pass renews.
Expand source code Browse git
class JsGeneratorCFFUnflattening(PinnedTransformer, BodyProcessingTransformer): """ Recover original code from generator-based state-machine CFF dispatchers. Handles the pattern where a function body is replaced with a generator function containing a while/switch state machine driven by multiple state variables. The pass holds the two models it reads, the semantic model and the intrinsic-write scan, for an invocation (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`). A recovery replaces the scaffolding of a machine with code made from copies of its states, and leaves the answers about the next machine no more permissive than rebuilt ones: - Whether the scaffolding is private (`_scaffolding_is_private`) is asked of the bindings the recovery of that machine removes, which are its own: no other recovery declares or reaches them. - Whether the recovered code declares only its own names (`_declares_only_its_own_names`) is asked of the function around the machine outside its scaffolding. Before deciding a machine whose body, or whose function outside the scaffolding, holds a body the invocation edited since the last renewal, the pass renews. - Whether a plain object inherits a routing key (`_provably_absent`) is read off the scan, which a recovery changes only where it turns a store of a value the scan follows only through bindings into a binding store, where it removes the `with` statement between a name and a binding whose values denote a root the scan watches, or where its code declares or assigns a name the scan watches (`_recovery_reveals`). After such a recovery, the pass renews. """ reads = ('_model', '_intrinsic_writes') def __init__(self): super().__init__() self._edited: dict[Node, list[Node]] = {} def visit_JsScript(self, node: JsScript): with self.holding_models(node): return super().visit_JsScript(node) def _forget_edits(self) -> None: self._edited = {} def _note_edit(self, body: Node) -> None: """ Record that the invocation edited *body*, under the script and every function that holds it, which are the places `_edited_around` asks about. """ cursor: Node | None = body while cursor is not None: if isinstance(cursor, (JsScript, *FUNCTION_NODES)): self._edited.setdefault(cursor, []).append(body) cursor = cursor.parent def _edited_around(self, parent: Node, removed: list[Statement]) -> bool: """ Whether a body the invocation edited since the last renewal is *parent*, the body holding a machine, or lies in the function holding it outside the scaffolding *removed*. """ function = parent if isinstance(parent, JsScript) else parent.parent if function is None: return False return any( body is parent or not any(body.is_descendant_of(statement) for statement in removed) for body in self._edited.get(function, ()) ) def _provably_absent(self, key: str) -> bool: """ Whether a plain object that does not own *key* reads `undefined` under it, and a store there makes a property of data: no prototype the language installs holds the key, and the program writes no prototype of a plain object (`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.chain_roots_unwritten`), or at least not this key (`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.key_written`), as a file patching another key of `Object`, such as a polyfill, does not. """ scan = self.held_models().intrinsic_writes return property_absent_from_written_chain(None, dict, key) and ( scan.chain_roots_unwritten(dict) or not scan.key_written('Object', key) ) def _process_body(self, parent: Node, body: list[Statement]) -> None: if not _is_a_function_body(parent): return i = 0 while i < len(body): match = _match_generator_cff(body, i) if match is None: i += 1 continue models = self.held_models() removed = body[match.gen_decl_index:match.scaffolding_end + 1] if self._edited_around(parent, removed): self.renew_models() model = models.model if not _scaffolding_is_private(model, body, match): i += 1 continue if ( match.routed and _spells_an_inherited_name_bare(match.switch_stmt, self._provably_absent) ): i += 1 continue machine = _extract_state_blocks(match, self._provably_absent) if machine is None: i += 1 continue slots = _initial_slots( machine.routing, match.scope_param_name, match.scope_default_inits, set(), {}, self._provably_absent, ) result = _execute_machine(machine, match, slots, main=True) if result is None: i += 1 continue recovered, settled = result main = _Activation( None, match.scope_default_inits, props=match.scope_prop_names, routing=settled, ) wrappers = _resolve_shared_wrappers( recovered, machine, match, main, self._provably_absent ) if wrappers is None or not _bind_main_arguments(recovered, match): i += 1 continue for activation in wrappers: if activation.function is not None: _declare_wrapper_scope_vars(activation) recovered = _declare_recovered_scope_vars(recovered, main) if ( _leaves_the_generator(recovered, match) or not _declares_only_its_own_names(model, body, match, recovered) ): i += 1 continue settled = _settle_returns( recovered, match, body[match.scaffolding_end + 1:], bool(wrappers), ) if settled is None: i += 1 continue recovered = settled for s in recovered: s.parent = parent start = match.gen_decl_index end = match.scaffolding_end reveals = _recovery_reveals(model, models.intrinsic_writes, removed, recovered, match) replacement = body[:start] + recovered + body[end + 1:] self._replace_body(parent, replacement) self._note_edit(parent) if reveals: self.renew_models() i = start + len(recovered)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
- Whether the scaffolding is private (