Module refinery.lib.scripts.js.deobfuscation.cff.statemachine
Recover original code from functions flattened into a state machine: the body of a function is
replaced by a call of a machine function, a while/switch loop whose discriminant is the sum of
its state, and each case changes the state by relative stores. The machine is a generator function
whose state is several variables.
Expand source code Browse git
"""
Recover original code from functions flattened into a state machine: the body of a function is
replaced by a call of a machine function, a `while`/`switch` loop whose discriminant is the sum of
its state, and each case changes the state by relative stores. The machine is a generator function
whose state is several variables.
"""
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, TypeVar
from refinery.lib.scripts import (
Expression,
Node,
Statement,
_clone_node,
_replace_in_parent,
set_child,
set_child_list,
tree_root,
)
from refinery.lib.scripts.analysis.cfg import ControlFlowModel
from refinery.lib.scripts.analysis.dominance import DominatorModel
from refinery.lib.scripts.js.analysis.cfg import build_cfg
from refinery.lib.scripts.js.analysis.environment import FileKind
from refinery.lib.scripts.js.analysis.evaluation import evaluated_later
from refinery.lib.scripts.js.analysis.intrinsics import denoting_leaves
from refinery.lib.scripts.js.analysis.model import (
annex_b_function_home,
is_direct_eval_call,
is_invocation_target,
is_member_write_target,
is_simple_assignment_target,
is_use_position,
lexically_declared_names,
pattern_identifiers,
references_own_arguments,
walk_receiver_scope,
)
from refinery.lib.scripts.js.deobfuscation.admission import CallAdmission
from refinery.lib.scripts.js.deobfuscation.helpers import (
BodyProcessingTransformer,
PinnedTransformer,
access_key,
declares_a_watched_name,
denoted_value,
fresh_name,
is_literal,
is_reference,
is_valid_identifier,
make_numeric_literal,
make_string_literal,
make_undefined_expression,
property_absent_from_written_chain,
property_key,
sanitize_inlined_body,
substitute_use_position,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
_ThrowSignal,
)
from refinery.lib.scripts.js.deobfuscation.namespaces import namespace_accesses
from refinery.lib.scripts.js.deobfuscation.parameters import parameter_list
from refinery.lib.scripts.js.model import (
FUNCTION_NODES,
LOOP_NODES,
JsArrayExpression,
JsArrayPattern,
JsArrowFunctionExpression,
JsAssignmentExpression,
JsAssignmentPattern,
JsAwaitExpression,
JsBinaryExpression,
JsBlockStatement,
JsBooleanLiteral,
JsBreakStatement,
JsCallExpression,
JsCatchClause,
JsClassBody,
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,
JsTryStatement,
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,
)
from refinery.lib.scripts.js.values import (
BINARY_OPS,
UNARY_OPS,
HostApproximated,
converts_uninterceptably,
js_strict_equal,
to_boolean,
)
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.cache import ModelCache
from refinery.lib.scripts.js.analysis.intrinsics import IntrinsicWrites
from refinery.lib.scripts.js.analysis.model import Binding, Scope, SemanticModel
from refinery.lib.scripts.js.values import Value
_StateValue = float | tuple[float, ...]
_StateEnv = dict[str, _StateValue]
_SlotPath = tuple[str, ...]
_Value = float | bool | str | tuple[float, ...] | _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 _FlagVariable(NamedTuple):
"""
The flag the call of a machine resets and its guard reads, held by a variable of the function
that holds the call.
"""
name: str
@property
def base(self) -> str:
return self.name
def spelled_by(self, node: Node | None) -> bool:
return isinstance(node, JsIdentifier) and node.name == self.name
class _FlagProperty(NamedTuple):
"""
The flag held by the property *key* of the object a variable *holder* of that function holds,
as the recovery of a machine around this one writes a variable of the function it recovers that
code from (`_Activation`).
"""
holder: str
key: str
@property
def base(self) -> str:
return self.holder
def spelled_by(self, node: Node | None) -> bool:
return isinstance(node, JsMemberExpression) and _slot_path(node) == (self.holder, self.key)
_Flag = _FlagVariable | _FlagProperty
class _CallSiteInfo(NamedTuple):
initial_state: list[_StateValue]
flag: _Flag | None
result_var: str | None
scaffolding_end: int
returns_value: bool
guarded: bool
undefined_reads: list[Expression]
initial_args: list[Expression]
call_index: int
reset: bool
class _WrapperFunctionInfo(NamedTuple):
initial_state: list[_StateValue]
rest_param_name: str | None
scope_arg: Expression
machine_reads: tuple[JsIdentifier, ...]
"""
The names the call of the wrapper reads the machine through: its callee, and the `runtime` it
hands on where the design has one.
"""
@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
index: Expression | None = None
@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 _SMGuardedTransition:
"""
A transition behind jumps ahead of it, `if (c) { stores; break; }`, each in *guards* with its
condition and its stores: at each arrival the first jump whose condition holds takes its
stores, and with none, the block takes *assignments*. A condition is evaluated on the state
alone, and one that does not evaluate refuses the machine.
"""
guards: list[tuple[Expression, list[_SMRawAssignment]]]
assignments: list[_SMRawAssignment]
@dataclass
class _SMExitTransition:
"""
The state machine reaches the end state after this block.
"""
pass
if TYPE_CHECKING:
_SMTransition = (
_SMLinearTransition | _SMConditionalTransition | _SMExitTransition | _SMGuardedTransition
)
@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 _MachineMatch:
machine: JsFunctionNode
machine_names: frozenset[str]
design: _Design
state_var_names: list[str]
initial_state: list[_StateValue]
end_state: int | float
loop_sum: Expression
switch_stmt: JsSwitchStatement
switch_label: str | None
scope_param_name: str | None
arg_var_name: str | None
flag: _Flag | None
result_var: str | None
machine_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)
undefined_reads: list[Expression] = field(default_factory=list)
runtime_name: str | None = None
initial_args: list[Expression] = field(default_factory=list)
calls: _Calls | None = None
call_index: int = -1
reset: bool = False
@property
def call_start(self) -> int:
"""
The index of the first statement of the call site: the statement behind the machine, or
one further down the same body where the machine is a declared function.
"""
return self.machine_index + 1 if self.call_index < 0 else self.call_index
def removed(self, body: list[Statement]) -> list[Statement]:
"""
The statements of *body* the recovery removes: the machine and its call site.
"""
return [body[self.machine_index], *body[self.call_start:self.scaffolding_end + 1]]
@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,
that of each routing slot (`_routing_paths`) whose value is known, and the calls of the
program's own functions the run may make (`_Calls`), where its design computes with them.
"""
states: _StateEnv
slots: _Slots = field(default_factory=dict)
calls: _Calls | None = None
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 _as_value(value: float | bool | str | _Undefined) -> Value:
"""
The value of `refinery.lib.scripts.js.values` that the primitive *value* is. That domain spells
`undefined` as `None`, which this module keeps for a value it does not know.
"""
return None if isinstance(value, _Undefined) else value
def _is_truthy(value: _Value) -> bool:
if isinstance(value, tuple):
return True
return to_boolean(_as_value(value))
def _strictly_equal(a: _Value, b: _Value) -> bool | None:
"""
JavaScript's `===` on the values a case test can make. An array is never a primitive, and two
arrays are equal only where they are one object, which the values do not say: `None`.
"""
if isinstance(a, tuple) or isinstance(b, tuple):
return None if isinstance(a, tuple) and isinstance(b, tuple) else False
return js_strict_equal(_as_value(a), _as_value(b))
def _operated(operator: str, left: _Value, right: _Value) -> _Value | None:
"""
What the binary *operator* computes from *left* and *right*
(`refinery.lib.scripts.js.values.BINARY_OPS`), or `None` where it is no operator of that table,
where an array would be converted, and where the engine approximates the result.
"""
if isinstance(left, tuple) or isinstance(right, tuple):
if operator not in ('===', '!==') or (equal := _strictly_equal(left, right)) is None:
return None
return equal == (operator == '===')
apply = BINARY_OPS.get(operator)
if apply is None:
return None
try:
result = apply(_as_value(left), _as_value(right))
except HostApproximated:
return None
return result if isinstance(result, (float, bool, str)) else None
def _value_key(value: _Value) -> tuple[object, ...]:
"""
A key under which two values the machine reads are one exactly where JavaScript's SameValue
holds of them: `===`, except that it tells `-0` from `0`, which a block reading the value would
print or divide by differently, and that `NaN` is itself. Two arrays of the state are one where
their elements are, since the machine's state is what they hold and not which object holds it.
"""
if isinstance(value, tuple):
return ('array', *map(_value_key, value))
if isinstance(value, _Undefined):
return ('undefined',)
if isinstance(value, bool):
return ('boolean', value)
if isinstance(value, str):
return ('string', value)
if math.isnan(value):
return ('number', 'NaN')
return ('number', value, is_negative_zero(value))
def _same_value(a: _Value, b: _Value) -> bool:
"""
Whether *a* and *b* are one value of the machine (`_value_key`).
"""
return _value_key(a) == _value_key(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, str, tuple)) or value is None:
return None
if not math.isfinite(value):
return None
return value
def _element(array: tuple[float, ...], key: _Value | None) -> _Value | None:
"""
The element of a state array under *key*, where the key is the index of one.
"""
if (index := _finite_number(key)) is None or not index.is_integer():
return None
if not 0 <= index < len(array):
return None
return array[int(index)]
def _eval_expr(node: Node | None, 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`.
A state variable may hold an array of numbers. An element of one is read at an index that
evaluates and lies in range, and an array literal is built of numbers and of spreads of arrays
where arrays iterate as built at the spread (`_Calls.iterates_arrays`). A call with an argument
that reads the state is run by the shared admission (`_Calls`), which may give a string or an
array as well; a call that reads no state is the function evaluator's to fold.
"""
node = strip_parens(node)
if isinstance(node, JsNumericLiteral):
return node.value
if isinstance(node, JsIdentifier):
return env.states.get(node.name)
if isinstance(node, JsMemberExpression):
holder = node.object
if isinstance(holder, JsIdentifier) and isinstance(array := env.states.get(holder.name), tuple):
if not node.computed or node.property is None:
return None
return _element(array, _eval_expr(node.property, env))
path = _slot_path(node)
return None if path is None else env.slots.get(path)
if isinstance(node, JsArrayExpression):
items: list[float] = []
for element in node.elements:
if element is None:
return None
if isinstance(element, JsSpreadElement):
if element.argument is None or env.calls is None:
return None
spread = _eval_expr(element.argument, env)
if not isinstance(spread, tuple) or not env.calls.iterates_arrays(element):
return None
items.extend(spread)
elif (number := _finite_number(_eval_expr(element, env))) is None:
return None
else:
items.append(number)
return tuple(items)
if (
isinstance(node, JsCallExpression)
and env.calls is not None
and any(_reads_the_state(argument, env) for argument in node.arguments)
):
arguments: list[_Value] = []
for argument in node.arguments:
if isinstance(argument, JsSpreadElement):
return None
if (value := _eval_expr(argument, env)) is None:
return None
arguments.append(value)
return env.calls(node, arguments)
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
if node.operator == '!':
return not _is_truthy(operand)
if isinstance(operand, tuple):
return None
number = UNARY_OPS[node.operator](_as_value(operand))
return number if isinstance(number, float) else None
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
return _operated(node.operator, lhs, rhs)
return None
def _reads_the_state(node: Node, env: _Env) -> bool:
"""
Whether *node* reads a state variable of *env* outside a nested function.
"""
if isinstance(node, FUNCTION_NODES):
return False
if isinstance(node, JsIdentifier):
return node.name in env.states and is_reference(node)
return any(_reads_the_state(child, env) for child in node.children())
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,
machine_names: frozenset[str],
num_state_vars: int,
) -> JsObjectExpression | None:
"""
If *node* is a call of the generator under one of *machine_names*,
generator(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 not in machine_names:
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,
machine_names: frozenset[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, machine_names, 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, design: _Design) -> _MachineMatch | 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.
"""
if (held := _held_generator(body[idx])) is None:
return None
stmt, holder = held
if not is_generator_function(stmt) or is_async_function(stmt):
return None
gen_name = holder.name
gen_names = frozenset({gen_name, *_own_name(stmt)})
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
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
flag = call_info.flag
if not routed or flag is not None:
slots = _scope_slots(switch_stmt, scope_param_name, scope_default_props)
if not routed and _spells_a_slot_bare(switch_stmt, slots):
return None
if flag is not None and flag.base in slots:
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_names,
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
if flag is not None and (flag.base in namespaces or flag.base in namespace_homes):
return None
return _MachineMatch(
machine=stmt,
machine_names=gen_names,
design=design,
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,
flag=call_info.flag,
result_var=call_info.result_var,
undefined_reads=call_info.undefined_reads,
machine_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,
reset=call_info.reset,
)
def _state_sum_call(node: Expression | None, states: str) -> JsCallExpression | None:
"""
The call `f(states)` of a function under a name with the state array as its one argument.
"""
if (
isinstance(node, JsCallExpression)
and isinstance(node.callee, JsIdentifier)
and len(node.arguments) == 1
and isinstance(argument := node.arguments[0], JsIdentifier)
and argument.name == states
):
return node
return None
def _match_array_machine(body: list[Statement], idx: int, design: _Design) -> _MachineMatch | None:
"""
Starting at index *idx* in *body*, test whether the statement is the machine of the design
whose state is one array of numbers, summed by a function of the program, with its call site
following:
function M(states, scope = {…}, runtime, arg) {
while (sum(states) !== END) { L: switch (sum(states)) { … } }
}
M([…]);
The machine is a plain function, declared or stored to a variable, and a `return` of
`undefined` may stand behind its loop. A declared machine may be called further down the same
body, as the recovery of an outer machine writes it; the statements between stay where they
are. Its call hands it one array, which the design reads
once the match is decided on (`_ArrayDesign.read_start`). Whether the code of the machine reads
what its invocation binds, and whether it holds a function declared in a block of strict code,
are asked of the whole function, and only once its shape matched.
"""
if (held := _held_generator(body[idx])) is None:
return None
stmt, holder = held
if is_generator_function(stmt) or is_async_function(stmt) or stmt.body is None:
return None
name = holder.name
names = frozenset({name, *_own_name(stmt)})
params = stmt.params
if not 3 <= len(params) <= 4:
return None
states, scope, runtime, *rest = params
if not isinstance(states, JsIdentifier) or not isinstance(runtime, JsIdentifier):
return None
if not isinstance(scope, JsAssignmentPattern) or not isinstance(scope.left, JsIdentifier):
return None
arg_var_name: str | None = None
if rest:
if not isinstance(rest[0], JsIdentifier):
return None
arg_var_name = rest[0].name
scope_param_name = scope.left.name
code = stmt.body.body
implicit_reads: list[Expression] = []
if len(code) == 2 and (implicit := _is_implicit_return(code[1])) is not None:
implicit_reads = implicit
code = code[:1]
if len(code) != 1 or not isinstance(while_stmt := code[0], JsWhileStatement):
return None
test = while_stmt.test
if not isinstance(test, JsBinaryExpression) or test.operator != '!==':
return None
if (loop_sum := _state_sum_call(test.left, states.name)) is None:
return None
if test.right is None or (end_state := _eval_number(test.right)) is None:
return None
inner: Statement | None = while_stmt.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
discriminant = _state_sum_call(inner.discriminant, states.name)
if discriminant is None or not isinstance(discriminant.callee, JsIdentifier):
return None
assert isinstance(loop_sum.callee, JsIdentifier)
if discriminant.callee.name != loop_sum.callee.name:
return None
if (scope_defaults := _extract_scope_default_props(params, scope.left.name)) is None:
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_default_props, scope_default_inits = scope_defaults
call_info = _find_generator_call_site(body, idx, name, plain=True)
if call_info is None and isinstance(stmt, JsFunctionDeclaration):
for start in range(idx + 2, len(body)):
if (call_info := _find_generator_call_site(body, idx, name, True, start)) is not None:
break
if call_info is None or len(call_info.initial_args) != 1:
return None
slots = _scope_slots(inner, scope_param_name, scope_default_props)
if _spells_a_slot_bare(inner, slots):
return None
flag = call_info.flag
if flag is not None and flag.base in slots:
return None
return _MachineMatch(
machine=stmt,
machine_names=names,
design=design,
state_var_names=[states.name],
initial_state=[],
end_state=end_state,
loop_sum=loop_sum,
switch_stmt=inner,
switch_label=switch_label,
scope_param_name=scope_param_name,
arg_var_name=arg_var_name,
flag=flag,
result_var=call_info.result_var,
undefined_reads=call_info.undefined_reads + implicit_reads,
machine_index=idx,
scaffolding_end=call_info.scaffolding_end,
returns_value=call_info.returns_value,
guarded=call_info.guarded,
scope_default_props=scope_default_props,
scope_default_inits=scope_default_inits,
runtime_name=runtime.name,
initial_args=call_info.initial_args,
call_index=call_info.call_index,
reset=call_info.reset,
)
def _held_generator(
stmt: Statement,
) -> tuple[JsFunctionDeclaration | JsFunctionExpression, JsIdentifier] | None:
"""
The function *stmt* binds to a name, with that name: a function declaration, or the store of
a function expression to a variable, as the recovery of a machine around this one writes the
declaration of a generator in the function it recovers.
"""
if isinstance(stmt, JsFunctionDeclaration):
return None if stmt.id is None else (stmt, stmt.id)
if not isinstance(stmt, JsExpressionStatement):
return None
store = stmt.expression
if (
isinstance(store, JsAssignmentExpression)
and store.operator == '='
and isinstance(store.left, JsIdentifier)
and isinstance(store.right, JsFunctionExpression)
):
return store.right, store.left
return None
def _own_name(generator: JsFunctionNode) -> tuple[str, ...]:
"""
The name a function expression binds for its own code, which the wrappers of a generator held
by a variable call it by, or nothing.
"""
if isinstance(generator, JsFunctionExpression) and generator.id is not None:
return (generator.id.name,)
return ()
def _reads_its_own_invocation(generator: JsFunctionNode) -> 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 _scope_slots(
switch_stmt: JsSwitchStatement,
scope_param_name: str | None,
defaults: list[str],
) -> set[str]:
"""
The names of the slots of a generator's scope object: its defaults and every key its code reads
or writes on it.
"""
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 slots
def _spells_a_slot_bare(switch_stmt: JsSwitchStatement, slots: set[str]) -> bool:
"""
Whether the code of a generator that reads its scope object through no `with` body spells the
name of one of its *slots* bare (`_scope_slots`). There the bare name means some other binding,
and once the strip turns `scope.K` into `K` it would name the slot instead.
"""
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 _slots_may_become_variables(
match: _MachineMatch,
activations: list[_Activation],
absent: Callable[[str], bool],
) -> bool:
"""
Whether every slot the runs of *match* keep on a scope object may become a variable of its own
name, as the strip makes it (`_strip_scope_param_prefix`): the slots of its main run
(`scope_prop_names`) and of the runs of its wrappers (*activations*). No prototype of a plain
object may supply the name (*absent*), as one supplies `constructor`, where a read of a slot no
run stored finds it; and where the code reads its scope object through no `with` body, it may
spell none of them bare (`_spells_a_slot_bare`). A key a run decodes from its state
(`_fold_scope_keys`) names a slot only the run shows.
"""
names = set(match.scope_prop_names).union(*(activation.props for activation in activations))
if not all(absent(name) for name in names):
return False
return match.routed or not _spells_a_slot_bare(match.switch_stmt, names)
def _block_functions(root: Node) -> Iterator[JsFunctionDeclaration]:
"""
Every function 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
yield node
def _holds_a_block_function(root: Node) -> bool:
"""
Whether a function is declared inside a block anywhere below *root* (`_block_functions`).
"""
return next(_block_functions(root), None) is not None
def _an_observed_copy_may_not_be_made(model: SemanticModel, generator: Node) -> bool:
"""
Whether *generator* may run as strict code, which makes no Annex B copy, and declares a function
in a block whose copy something observes. The recovered code is read apart from the tree and
gives such a function the copy (`_hoisted_names`), which the file may then never have made.
An ES module makes no copy in any reading, so the model holds none to ask about, and any
function declared in a block of the generator counts, as it does for code the text makes strict
(`_match_generator_cff`).
"""
if not model.may_run_as_strict(generator):
return False
if model.file_kind is FileKind.es_module:
return _holds_a_block_function(generator)
return any(model.annex_b_copy_observed(function) for function in _block_functions(generator))
def _flag_spelled(node: Expression | None) -> _Flag | None:
"""
The flag *node* spells: a name, or a property of a variable under a static key
(`_FlagProperty`).
"""
if node is None:
return None
if isinstance(node, JsIdentifier):
return _FlagVariable(node.name)
if (path := _slot_path(node)) is not None and len(path) == 2:
return _FlagProperty(*path)
return None
def _spells_undefined(node: Expression | None) -> list[Expression] | None:
"""
The names the recovery has to find unbound for *node* to spell `undefined`, or `None` where it
does not: none for `void` of a number, which means it wherever it stands, and the name itself
for `undefined`.
"""
if (
isinstance(node, JsUnaryExpression)
and node.operator == 'void'
and isinstance(node.operand, JsNumericLiteral)
):
return []
if isinstance(node, JsIdentifier) and node.name == 'undefined':
return [node]
return None
def _did_return_reset(stmt: Statement) -> tuple[_Flag, list[Expression], bool] | None:
"""
The flag *stmt* names ahead of the generator's call, as `var didReturn;` or as a store of
`undefined` to it (`_spells_undefined`), with the names that store relies on and whether it
resets the flag on every run of the call site, or `None`. Only the store does: a `var` resets
nothing.
"""
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 _FlagVariable(decls[0].id.name), [], False
if isinstance(stmt, JsExpressionStatement):
expr = stmt.expression
if (
isinstance(expr, JsAssignmentExpression)
and expr.operator == '='
and (flag := _flag_spelled(expr.left)) is not None
and (reads := _spells_undefined(expr.right)) is not None
):
return flag, reads, True
return None
def _is_result_guard(
stmt: Statement,
flag: _Flag,
result_var: str,
ends: bool,
) -> list[Expression] | None:
"""
The names spelling `undefined` that *stmt* relies on where it is the guard
if (didReturn) { return result; }
and nothing more, or `None` where it is not. Where it *ends* the body of a function, its `else`
may hold the return of `undefined` that ends that body (`_is_implicit_return`).
"""
if not isinstance(stmt, JsIfStatement) or not flag.spelled_by(stmt.test):
return None
reads: list[Expression] = []
if (otherwise := stmt.alternate) is not None:
if isinstance(otherwise, JsBlockStatement) and len(otherwise.body) == 1:
otherwise = otherwise.body[0]
if not ends or (implicit := _is_implicit_return(otherwise)) is None:
return None
reads = implicit
consequent = stmt.consequent
if isinstance(consequent, JsBlockStatement) and len(consequent.body) == 1:
consequent = consequent.body[0]
if not (
isinstance(consequent, JsReturnStatement)
and isinstance(consequent.argument, JsIdentifier)
and consequent.argument.name == result_var
):
return None
return reads
def _is_implicit_return(stmt: Statement | None) -> list[Expression] | None:
"""
The names spelling `undefined` that *stmt* relies on where it returns `undefined`, as the
obfuscator ends the body of every function whose code it puts into the machine of the function
around it, or `None`: that body ends in the call of its own machine, and the recovery of the
machine around it writes the return behind the guard or as its `else`.
"""
if not isinstance(stmt, JsReturnStatement):
return None
if stmt.argument is None:
return []
return _spells_undefined(stmt.argument)
def _find_generator_call_site(
body: list[Statement],
gen_idx: int,
gen_name: str,
plain: bool = False,
start: int | None = None,
) -> _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, or not at all where the guard names
it, the result assigned to a variable declared elsewhere, and the call returned directly or
made for nothing. Only a store resets the flag on every run of the call site (`reset`); a
`var` resets nothing, and its flag holds `undefined` there only on the first run
(`_scaffolding_is_private`). 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`). Where *plain*, the call is the plain call `M(array)` of a machine
that is no generator, and *start* is the index the call site begins at where it does not stand
right behind the machine. Returns a `_CallSiteInfo` or `None`.
"""
pos = gen_idx + 1 if start is None else start
call_index = pos
undefined_reads: list[Expression] = []
flag: _Flag | None = None
reset = False
if pos < len(body) and (found := _did_return_reset(body[pos])) is not None:
flag, undefined_reads, reset = found
pos += 1
if pos >= len(body):
return None
call_expr = _extract_generator_call(body[pos], gen_name, plain)
if call_expr is None:
return None
call_node, result_var = call_expr
initial_state: list[_StateValue] = []
if not plain:
for arg in call_node.arguments:
val = _eval_number(arg)
if val is None:
return None
initial_state.append(val)
scaffolding_end = pos
ends_a_function = (owner := body[gen_idx].parent) is not None and _is_a_function_body(owner)
guard_reads = None
if result_var is not None and scaffolding_end + 1 < len(body):
guard = body[scaffolding_end + 1]
named = flag
if (
named is None
and isinstance(guard, JsIfStatement)
and isinstance(spelled := _flag_spelled(guard.test), _FlagVariable)
):
named = spelled
if named is not None:
guard_reads = _is_result_guard(
guard,
named,
result_var,
ends_a_function and scaffolding_end + 2 == len(body),
)
if guard_reads is not None:
flag = named
guarded = guard_reads is not None
if guard_reads is not None:
undefined_reads.extend(guard_reads)
scaffolding_end += 1
if (
ends_a_function
and scaffolding_end + 2 == len(body)
and (implicit := _is_implicit_return(body[-1])) is not None
):
undefined_reads.extend(implicit)
scaffolding_end += 1
return _CallSiteInfo(
initial_state,
flag,
result_var,
scaffolding_end,
isinstance(body[pos], JsReturnStatement),
guarded,
undefined_reads,
list(call_node.arguments),
call_index,
reset,
)
class _GeneratorCallInfo(NamedTuple):
call_node: JsCallExpression
result_var: str | None
def _extract_generator_call(
stmt: Statement,
gen_name: str,
plain: bool = False,
) -> _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"];
Where *plain*, the call is `gen(...)` itself. 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 = (expr if isinstance(expr, JsCallExpression) else None) if plain else _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_names: frozenset[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"]; }
where `gen` is one of the names the generator is called by, *gen_names*.
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 not in gen_names:
return None
args = gen_call.arguments
if len(args) < num_state_vars + 1:
return None
initial_state: list[_StateValue] = []
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], (gen_call.callee,)
)
def _plain_wrapper_call(node: JsFunctionExpression, match: _MachineMatch) -> JsCallExpression | None:
"""
The call `M(states, scope, runtime, rest)` a wrapper of the machine of *match* returns where
that is its whole body and it hands on the machine's own `runtime`, the design whose state is
one array.
"""
if node.body is None:
return None
body = node.body.body
if len(body) != 1 or not isinstance(stmt := body[0], JsReturnStatement):
return None
call = stmt.argument
if (
not isinstance(call, JsCallExpression)
or not isinstance(call.callee, JsIdentifier)
or call.callee.name not in match.machine_names
or len(call.arguments) != 4
):
return None
runtime = call.arguments[2]
if not isinstance(runtime, JsIdentifier) or runtime.name != match.runtime_name:
return None
return call
def _detect_plain_wrapper(
node: Expression,
match: _MachineMatch,
) -> _WrapperFunctionInfo | None:
"""
The wrapper `function (...rest) { return M(states, scope, runtime, rest); }` of the machine of
*match*, the design whose state is one array. Its start state is read from numbers alone: a
start state the run computes was put in its place as an array literal by the run that made the
wrapper (`_fold_scope_keys`).
"""
if not isinstance(node, JsFunctionExpression) or (call := _plain_wrapper_call(node, match)) is None:
return None
states, scope_arg, runtime, handed = call.arguments
entry = _eval_expr(states, _Env({}))
if not isinstance(entry, tuple):
return None
params = node.params
if len(params) != 1 or not isinstance(rest := params[0], JsRestElement):
return None
if not isinstance(rest.argument, JsIdentifier):
return None
if not isinstance(handed, JsIdentifier) or handed.name != rest.argument.name:
return None
assert isinstance(call.callee, JsIdentifier) and isinstance(runtime, JsIdentifier)
return _WrapperFunctionInfo([entry], rest.argument.name, scope_arg, (call.callee, runtime))
@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: _MachineMatch,
absent: Callable[[str], bool],
) -> _StateMachine | None:
"""
Read the labels of the switch in source order, each with the block it runs (`_read_block`)
from the statements of its body that can run on the graph of the generator. 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, frozenset(match.state_var_names))
graphs = {id(match.machine): build_cfg(match.machine)}
flow = DominatorModel(ControlFlowModel(graphs, evaluated_later))
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, flow, 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], states: frozenset[str] = frozenset()) -> 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 and path[0] not in states:
paths.add(path)
return frozenset(paths)
class _RunningBody(NamedTuple):
"""
The statements of a case body that can run, and whether control never runs off the end of the
last of them (`closed`).
"""
statements: list[Statement]
closed: bool
def _running_statements(stmts: list[Statement], flow: DominatorModel) -> _RunningBody | None:
"""
The statements of a case body that can run, as the shared control-flow model of the generator
answers it (`refinery.lib.scripts.analysis.dominance.DominatorModel.never_runs`): the
obfuscator leaves the transition of a block behind a `return` and puts jumps it never takes
behind a transition. A statement that never runs declares its names all the same, so a body
where one declares a name gives `None`. Whether control runs off the end of the body is the same
model's answer (`refinery.lib.scripts.analysis.dominance.DominatorModel.completes`).
"""
runs: list[Statement] = []
dead: list[Statement] = []
for stmt in stmts:
(dead if flow.never_runs(stmt) else runs).append(stmt)
if dead and _declared_names_in_stmts(dead):
return None
return _RunningBody(runs, bool(runs) and not flow.completes(runs[-1]))
def _read_block(
stmts: list[Statement],
flow: DominatorModel,
match: _MachineMatch,
routing: frozenset[_SlotPath],
last: bool,
absent: Callable[[str], bool],
) -> _SMBlock | None:
"""
The block the statements of a case body that can run make (`_running_statements`, asked of
*flow*; `_parse_case_body`) with the stores they make to routing slots (`_routing_stores`), or
`None` where the recovery cannot read either.
"""
if (body := _running_statements(stmts, flow)) is None:
return None
runs = body.statements
parsed = _parse_case_body(runs, match.state_var_names, match.switch_label, last, body.closed)
if parsed is None:
return None
stores = _routing_stores(runs, 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. A name that code between a statement and the write binds anew
(`_walk_scoped`), as a parameter of a function the statement holds does, means that binding
and no longer reaches the `with` statement.
"""
names = {name for path in routing for name in path[1:]}
holders = {name for path in routing for name in path[:-1]}
watched = frozenset(names | holders)
for stmt in stmts:
for node, shadowed in _walk_scoped(stmt, watched):
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 := _holder_name(node.object)) in holders
and not (isinstance(node.object, JsIdentifier) and holder in shadowed)
):
return True
elif isinstance(node, JsVariableDeclarator):
declared: set[str] = set()
_collect_binding_names(node.id, declared)
if not names.isdisjoint(declared - shadowed):
return True
elif (
isinstance(node, JsIdentifier)
and node.name in names
and node.name not in shadowed
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,
closed: bool,
) -> tuple[list[Statement], _SMTransition] | None:
"""
Separate the statements of a case body that run into payload statements and the state
transition the obfuscator ends them with (`_split_case_body`). A body that 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`.
"""
parsed = _split_case_body(stmts, var_names, switch_label, last, closed)
if parsed is None:
return None
payload, transition = parsed
if _writes_a_state_variable(stmts, 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,
closed: bool,
) -> tuple[list[Statement], _SMTransition] | None:
"""
Separate the statements of a case body that run into payload statements and a state
transition. A body that ends in a `return` or `throw` leaves the machine, and so does a *closed*
one whose end control never reaches, such as one ending in a `switch` every case of which
returns: nothing of it stores the next state. 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`.
"""
if stmts and isinstance(stmts[-1], (JsReturnStatement, JsThrowStatement)):
return (stmts, _SMExitTransition())
if (split := _split_transition_at_the_end(stmts, var_names, switch_label, last)) is not None:
return split
return (stmts, _SMExitTransition()) if closed else None
def _split_transition_at_the_end(
stmts: list[Statement],
var_names: list[str],
switch_label: str | None,
last: bool,
) -> tuple[list[Statement], _SMTransition] | None:
"""
The payload of a case body and the transition it ends in (`_split_case_body`), or `None`.
"""
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
guards: list[tuple[Expression, list[_SMRawAssignment]]] = []
while payload and (guard := _state_guard(payload[-1], var_names, switch_label)) is not None:
guards.insert(0, guard)
payload = payload[:-1]
if guards:
transition = _SMGuardedTransition(guards, assignments)
else:
transition = _SMLinearTransition(assignments=assignments)
else:
return None
if not terminated and not last:
return None
return (payload, transition)
def _state_guard(
stmt: Statement,
var_names: list[str],
switch_label: str | None,
) -> tuple[Expression, list[_SMRawAssignment]] | None:
"""
The condition and the stores of a jump `if (c) { stores; break; }` ahead of a transition, which
leaves the block with its own stores where its condition holds. A jump without stores enters
the same block again.
"""
if (
not isinstance(stmt, JsIfStatement)
or stmt.alternate is not None
or stmt.test is None
or stmt.consequent is None
):
return None
inner, terminated = _strip_trailing_break(_block_stmts(stmt.consequent), switch_label)
if not terminated:
return None
if not inner:
return stmt.test, []
if (split := _split_transition(inner, var_names)) is None:
return None
rest, assignments = split
if rest:
return None
return stmt.test, assignments
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 (
not isinstance(expr, JsAssignmentExpression)
or expr.operator not in ('=', '+=')
or expr.right is None
):
return None
target = expr.left
if isinstance(target, JsIdentifier) and target.name in var_names:
return _SMRawAssignment(target.name, expr.operator, expr.right, expr)
if (
isinstance(target, JsMemberExpression)
and target.computed
and isinstance(target.object, JsIdentifier)
and target.object.name in var_names
and target.property is not None
):
return _SMRawAssignment(target.object.name, expr.operator, expr.right, expr, target.property)
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
if isinstance(transition, _SMGuardedTransition):
return [a for _, guard in transition.guards for a in guard] + transition.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
elif (
isinstance(node, JsMemberExpression)
and isinstance(node.object, JsIdentifier)
and node.object.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:
env = _Env(states, current.slots, current.calls)
held = states.get(assign.name)
index: int | None = None
if assign.index is not None:
if not isinstance(held, tuple):
return None
key = _finite_number(_eval_expr(assign.index, env))
if key is None or not key.is_integer() or not 0 <= key < len(held):
return None
index = int(key)
old = held[index]
elif isinstance(held, tuple) or held is None:
return None
else:
old = held
value = _eval_expr(assign.rhs, env)
if value is not None and assign.operator == '+=':
value = _operated('+', old, value)
if (number := _finite_number(value)) is None:
return None
if index is None:
states[assign.name] = number
else:
assert isinstance(held, tuple)
states[assign.name] = (*held[:index], number, *held[index + 1:])
return _Env(states, current.slots, current.calls)
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 (equal := _strictly_equal(value, discriminant)) is None:
return None
if equal:
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[_StateValue]) -> _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: _MachineMatch,
) -> list[Statement] | None:
"""
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, or `None` where a key of an access to the scope object does not decode
(`_fold_scope_keys`).
"""
if not stmts:
return []
result = [_clone_node(stmt) for stmt in stmts]
if not _fold_scope_keys(stmts, result, state, match):
return None
for clone in result:
_substitute_in_scope(clone, 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
_N = TypeVar('_N', bound=Node)
@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)
leaves: bool = False
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: _MachineMatch,
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, env.calls)
payload = _process_payload(block.payload, env.states, match)
if payload is None:
return None
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, _SMGuardedTransition):
chosen = transition.assignments
for condition_of_jump, assignments in transition.guards:
if (holds := _eval_expr(condition_of_jump, env)) is None:
return None
if _is_truthy(holds):
chosen = assignments
break
if (target := arrive(_apply_raw_transition(chosen, 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 = _process_payload(transition.true_prefix, env.states, match)
false_prefix = _process_payload(transition.false_prefix, env.states, match)
if true_prefix is None or false_prefix is None:
return None
true_prefix_payload = true_prefix
false_prefix_payload = false_prefix
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,
leaves=isinstance(transition, _SMExitTransition),
)
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 _post_dominators(successors: dict[int, list[int]], exit_id: int) -> dict[int, int]:
"""
The immediate post-dominator of every block of the graph *successors* describes that has a way
to *exit_id*: the nearest block every way from it to *exit_id* passes, computed as dominators
of the reversed graph (Cooper, Harvey and Kennedy). Neither *exit_id* nor a block without such
a way has one.
"""
predecessors: dict[int, list[int]] = {exit_id: []}
for disc, succs in successors.items():
predecessors.setdefault(disc, [])
for succ in succs:
predecessors.setdefault(succ, []).append(disc)
visited: set[int] = set()
rpo: list[int] = []
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 = predecessors.get(current, [])
if idx < len(preds):
stack.append((current, idx + 1))
if (pred := preds[idx]) not in visited:
stack.append((pred, 0))
else:
rpo.append(current)
rpo.reverse()
position = {disc: index for index, disc in enumerate(rpo)}
ipdom: dict[int, int] = {exit_id: exit_id}
def intersect(a: int, b: int) -> int:
while a != b:
while position[a] > position[b]:
a = ipdom[a]
while position[b] > position[a]:
b = ipdom[b]
return a
changed = True
while changed:
changed = False
for disc in rpo:
if disc == exit_id:
continue
succs = [succ for succ in successors.get(disc, []) if succ in ipdom]
if not succs:
continue
new_ipdom = succs[0]
for succ in succs[1:]:
new_ipdom = intersect(new_ipdom, succ)
if ipdom.get(disc) != new_ipdom:
ipdom[disc] = new_ipdom
changed = True
del ipdom[exit_id]
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 _block_code(node: _CFGNode) -> list[Node]:
"""
The code the structuring writes for the block *node*: its payload, the condition of its
transition, and what each branch of it runs first.
"""
code: list[Node] = [*node.payload, *node.true_prefix_payload, *node.false_prefix_payload]
if node.condition is not None:
code.append(node.condition)
return code
class _NodeParts(NamedTuple):
"""
What the structuring writes for one block: its payload, the condition of its transition, and
the statements each branch of that transition runs first.
"""
payload: list[Statement]
condition: Expression | None
true_prefix: list[Statement]
false_prefix: list[Statement]
class _Region:
"""
The blocks one walk of the structuring places, up to its *exit*: the end of the machine for the
walk over the run, the header of the loop for a walk over that loop's body (*members*, `None`
for the run). A block that has a way to the exit not passing a block that leaves the machine is
steady, and the join of a steady conditional is its post-dominator over those ways (`ipdom`). A
block without such a way (`tails`) only leaves the machine or the loop, and the structuring
writes it wherever control reaches it. `reach` holds the blocks each block reaches and
`dominated` the blocks each block dominates, as bits by their position in the order the graph
runs (`order`); `leaving_ipdom` holds post-dominators over every way, each way out of the
region ending at the exit.
"""
def __init__(
self,
cfg: _CFG,
order: dict[int, int],
dominated: dict[int, int],
exit_disc: int,
members: set[int] | None,
):
nodes = {disc for disc in (cfg.nodes if members is None else members) if disc != exit_disc}
steady = {disc for disc in nodes if not cfg.nodes[disc].leaves}
self.exit = exit_disc
self.members = members
self.dominated = dominated
steady_ways = {
disc: [
succ for succ in cfg.nodes[disc].successors if succ in steady or succ == exit_disc
] for disc in steady
}
every_way = {
disc: [
succ if succ in nodes else exit_disc for succ in cfg.nodes[disc].successors
] for disc in nodes
}
self.ipdom = _post_dominators(steady_ways, exit_disc)
self.tails = {disc for disc in nodes if disc not in self.ipdom}
self.leaving_ipdom = _post_dominators(every_way, exit_disc)
self.at_position = {order[disc]: disc for disc in nodes}
self.reach = {disc: 1 << order[disc] for disc in nodes}
ordered = sorted(nodes, key=order.__getitem__, reverse=True)
changed = True
while changed:
changed = False
for disc in ordered:
reached = self.reach[disc]
for succ in cfg.nodes[disc].successors:
reached |= self.reach.get(succ, 0)
if reached != self.reach[disc]:
self.reach[disc] = reached
changed = True
def holds(self, disc: int) -> bool:
"""
Whether the block *disc* lies in the region rather than where the loop it is the body of
is left to.
"""
return self.members is None or disc in self.members
def join(self, cfg: _CFG, disc: int) -> int:
"""
Where the branches of the conditional *disc* meet again. For a steady conditional, the
nearest block that every way on from it not leaving the machine passes: a branch that always
leaves stands in the conditional, and the code after the conditional is the other branch's.
For one that only leaves: the block both branches reach that dominates every block both
reach, so that both run into the code after the conditional through it; else the nearest
block every way on from it passes; else, where the branches leave the region apart, the
false successor: the true branch then runs on into it or leaves.
"""
if disc == self.exit:
return self.exit
if (found := self.ipdom.get(disc)) is not None:
return found
true_succ, false_succ = cfg.nodes[disc].successors
if common := self.reach.get(true_succ, 0) & self.reach.get(false_succ, 0):
first = self.at_position[(common & -common).bit_length() - 1]
if not common & ~self.dominated[first]:
return first
if (found := self.leaving_ipdom.get(disc)) is not None and found != self.exit:
return found
return false_succ
class _CopiesOutgrowTheRun(Exception):
"""
Raised where the copies the structuring writes would hold more code than the blocks of the run
(`_Structuring.allowance`), or would make a wrapper: a copy of a wrapper is a function of its
own, which the recovery resolves as a run of its own, and the allowance does not count that run.
"""
def _code_size(code: Sequence[Node]) -> int:
"""
The number of syntax nodes in *code*.
"""
return sum(1 for part in code for _ in part.walk())
def _refers_to_one_of(code: Sequence[Node], names: frozenset[str]) -> bool:
"""
Whether *code* refers to one of *names* anywhere below it.
"""
return any(
isinstance(node, JsIdentifier) and node.name in names and is_reference(node)
for part in code
for node in part.walk()
)
class _Structuring:
"""
The recovery of structured code from the graph of one run: loops innermost first, each written
once and then standing for its header (`collapsed`), then the run itself, by walks over regions
(`_Region`) that place blocks in the order the graph runs them. A block that only leaves is
placed wherever control reaches it; where a block is placed a second time, the walk writes a
copy of its code (`_parts`, `_collapsed`), since a tree holds a node in one place. Blocks whose
ways part and share blocks without meeting again make every level of them double what the
levels below write, so the copies may hold at most as much code as the blocks of the run
(`allowance`), and none of them may call the generator under one of its *machine_names*;
beyond that, the run is not written.
A loop is written only where every way out of it goes to one block. A `break` goes to where the
code after the loop continues, and nothing else can be said with one.
"""
def __init__(
self,
cfg: _CFG,
idom: dict[int, int | None],
machine_names: frozenset[str],
):
self.cfg = cfg
self.machine_names = machine_names
self.collapsed: dict[int, list[Statement]] = {}
self.placed: set[int] = set()
self.placed_loops: set[int] = set()
self.homes: dict[Node, tuple[int, int]] = {}
self.allowance = sum(_code_size(_block_code(node)) for node in cfg.nodes.values())
self.order = {disc: index for index, disc in enumerate(_reverse_postorder(cfg))}
self.dominated = {disc: 1 << index for disc, index in self.order.items()}
for disc in sorted(self.order, key=self.order.__getitem__, reverse=True):
if (parent := idom.get(disc)) is not None:
self.dominated[parent] |= self.dominated[disc]
def run(self, loops: list[_NaturalLoop]) -> list[Statement] | None:
try:
for loop in sorted(loops, key=lambda lp: len(lp.body)):
if (body := self._loop(loop)) is None:
return None
self.collapsed[loop.header] = body
for body_node in loop.body:
if body_node != loop.header:
self.collapsed[body_node] = []
region = _Region(self.cfg, self.order, self.dominated, self.cfg.exit, None)
return self._acyclic(region, self.cfg.entry, self.cfg.exit, set())
except _CopiesOutgrowTheRun:
return None
def _copy(self, code: Sequence[_N]) -> list[_N]:
"""
A copy of *code*, charged to what the copies may hold in all (`allowance`). Code that calls
the generator, as the code making a wrapper does, is not copied (`_CopiesOutgrowTheRun`).
"""
self.allowance -= _code_size(code)
if self.allowance < 0 or _refers_to_one_of(code, self.machine_names):
raise _CopiesOutgrowTheRun
return [_clone_node(part) for part in code]
def _parts(self, disc: int) -> _NodeParts:
"""
The code of the block *disc* as a walk places it: the block's own the first time, a copy
every other time. Each statement of its payload is recorded with the block and its position
there (`homes`).
"""
node = self.cfg.nodes[disc]
if disc not in self.placed:
self.placed.add(disc)
parts = _NodeParts(
list(node.payload),
node.condition,
list(node.true_prefix_payload),
list(node.false_prefix_payload),
)
else:
parts = _NodeParts(
self._copy(node.payload),
self._copy([node.condition])[0] if node.condition is not None else None,
self._copy(node.true_prefix_payload),
self._copy(node.false_prefix_payload),
)
for index, stmt in enumerate(parts.payload):
self.homes[stmt] = (disc, index)
return parts
def _collapsed(self, disc: int) -> list[Statement]:
"""
The code the loop whose header is *disc* was written as, or a copy of it where it was placed
before; nothing for another block of a loop, whose code the loop's holds.
"""
code = self.collapsed[disc]
if disc in self.placed_loops:
return self._copy(code)
self.placed_loops.add(disc)
return list(code)
def _loop(self, loop: _NaturalLoop) -> list[Statement] | None:
"""
One natural loop as a `while` statement: tested at its header where it is left from there
alone, else `while (true)` left with a `break`. `None` where the loop is left to more than
one block.
"""
nodes = self.cfg.nodes
targets = {
succ for disc in loop.body for succ in nodes[disc].successors if succ not in loop.body
}
if len(targets) > 1:
return None
region = _Region(self.cfg, self.order, self.dominated, loop.header, loop.body)
header = nodes[loop.header]
if (
header.condition is not None
and len(header.successors) == 2
and not header.payload
and not any(
succ not in loop.body
for disc in loop.body if disc != loop.header
for succ in nodes[disc].successors
)
):
true_succ, false_succ = header.successors
if true_succ not in loop.body and true_succ in nodes:
parts = self._parts(loop.header)
assert parts.condition is not None
body_entry = false_succ
condition: Expression = JsUnaryExpression(
operator='!',
operand=parts.condition,
prefix=True,
)
body_prefix, exit_prefix = parts.false_prefix, parts.true_prefix
elif false_succ not in loop.body and false_succ in nodes:
parts = self._parts(loop.header)
assert parts.condition is not None
body_entry = true_succ
condition = parts.condition
body_prefix, exit_prefix = parts.true_prefix, parts.false_prefix
else:
return self._infinite_loop(loop, region)
body = self._acyclic(region, body_entry, loop.header, set())
return [
JsWhileStatement(
test=condition,
body=JsBlockStatement(body=[*body_prefix, *body]),
),
*exit_prefix,
]
return self._infinite_loop(loop, region)
def _infinite_loop(self, loop: _NaturalLoop, region: _Region) -> list[Statement]:
"""
A loop without a simple test at its header as `while (true)`, left with `break`.
"""
return [JsWhileStatement(
test=JsBooleanLiteral(value=True),
body=JsBlockStatement(body=self._loop_body(loop.header, region)),
)]
def _branches(
self,
region: _Region,
disc: int,
parts: _NodeParts,
join: int,
visited: set[int],
) -> Statement:
"""
The `if` statement of the conditional *disc*, each branch running from its successor up to
*join*. A block either branch places counts as placed for what follows.
"""
true_succ, false_succ = self.cfg.nodes[disc].successors
true_visited = set(visited)
true_stmts = list(parts.true_prefix)
if true_succ != join:
true_stmts.extend(self._acyclic(region, true_succ, join, true_visited))
false_visited = set(visited)
false_stmts = list(parts.false_prefix)
if false_succ != join:
false_stmts.extend(self._acyclic(region, false_succ, join, false_visited))
visited.update(true_visited)
visited.update(false_visited)
assert parts.condition is not None
return _build_js_if(parts.condition, true_stmts, false_stmts)
def _acyclic(
self,
region: _Region,
entry: int,
exit_disc: int,
visited: set[int],
) -> list[Statement]:
"""
The blocks of *region* from *entry* up to *exit_disc* as a statement sequence, with an `if`
for each conditional whose branches run up to its join (`_Region.join`). A block another
walk placed is placed again only where it only leaves (`_Region.tails`); a block outside the
loop the region is the body of is where the loop is left, a `break`.
"""
result: list[Statement] = []
here: set[int] = set()
worklist: deque[int] = deque([entry])
while worklist:
disc = worklist.popleft()
if disc == exit_disc or disc in here:
continue
here.add(disc)
if not region.holds(disc):
result.append(JsBreakStatement())
continue
if disc in visited and disc not in region.tails:
continue
visited.add(disc)
node = self.cfg.nodes[disc]
if disc in self.collapsed:
result.extend(self._collapsed(disc))
worklist.extend(node.successors)
continue
parts = self._parts(disc)
result.extend(parts.payload)
if node.condition is not None and len(node.successors) == 2:
join = region.join(self.cfg, disc)
result.append(self._branches(region, disc, parts, join, visited))
worklist.appendleft(join)
else:
worklist.extend(node.successors)
return result
def _loop_body(self, header: int, region: _Region) -> list[Statement]:
"""
The body of the loop whose header is *header*, for `while (true)`: a branch back to the
header is a `continue`, and one out of the loop a `break`.
"""
result: list[Statement] = []
visited: set[int] = set()
here: set[int] = set()
worklist: deque[int] = deque([header])
while worklist:
disc = worklist.popleft()
if disc in here:
continue
here.add(disc)
if not region.holds(disc):
result.append(JsBreakStatement())
continue
if disc in visited and disc not in region.tails:
continue
visited.add(disc)
node = self.cfg.nodes[disc]
if disc in self.collapsed:
result.extend(self._collapsed(disc))
worklist.extend(succ for succ in node.successors if succ != header)
continue
parts = self._parts(disc)
result.extend(parts.payload)
if node.condition is None or len(node.successors) != 2:
for succ in node.successors:
if succ != header:
worklist.append(succ)
continue
assert parts.condition is not None
true_succ, false_succ = node.successors
true_in_loop = region.holds(true_succ)
false_in_loop = region.holds(false_succ)
condition = parts.condition
def negated() -> Expression:
return JsUnaryExpression(operator='!', operand=condition, prefix=True)
if true_succ == header or false_succ == header:
back_on_true = true_succ == header
other = false_succ if back_on_true else true_succ
back_prefix = parts.true_prefix if back_on_true else parts.false_prefix
other_prefix = parts.false_prefix if back_on_true else parts.true_prefix
if not region.holds(other):
result.append(JsIfStatement(
test=negated() if back_on_true else condition,
consequent=JsBlockStatement(body=[*other_prefix, JsBreakStatement()]),
))
result.extend(back_prefix)
else:
result.append(JsIfStatement(
test=condition if back_on_true else negated(),
consequent=JsBlockStatement(body=[*back_prefix, JsContinueStatement()]),
))
result.extend(other_prefix)
worklist.append(other)
continue
if not true_in_loop and not false_in_loop:
if parts.true_prefix or parts.false_prefix:
result.append(_build_js_if(
parts.condition,
[*parts.true_prefix, JsBreakStatement()],
[*parts.false_prefix, JsBreakStatement()],
))
else:
result.append(_build_js_if(parts.condition, [], []))
result.append(JsBreakStatement())
continue
if not true_in_loop:
result.append(JsIfStatement(
test=parts.condition,
consequent=JsBlockStatement(body=[*parts.true_prefix, JsBreakStatement()]),
))
result.extend(parts.false_prefix)
worklist.append(false_succ)
continue
if not false_in_loop:
result.append(JsIfStatement(
test=negated(),
consequent=JsBlockStatement(body=[*parts.false_prefix, JsBreakStatement()]),
))
result.extend(parts.true_prefix)
worklist.append(true_succ)
continue
join = region.join(self.cfg, disc)
result.append(self._branches(region, disc, parts, join, visited))
if join != header:
worklist.appendleft(join)
return result
def _fold_scope_keys(
originals: Sequence[Node],
clones: Sequence[Node],
state: _StateEnv,
match: _MachineMatch,
) -> bool:
"""
Put what the run computes from *state* into *clones*, the copies of *originals*: the string
each computed key of an access to the scope object, or to a member of it, evaluates to in place
of the key, and the array a wrapper's call starts the machine in as an array literal. Each is
evaluated on the original node, which anchors the calls that make it, and only where it runs
at the state: none in code that runs at a time of its own
(`refinery.lib.scripts.js.analysis.evaluation.evaluated_later`), such as a nested function,
which reads the state when it is called, or the initializer of a field written without
`static`, which reads it when an instance is made, and none whose scope object or state
variables something between the block and the key binds anew (`_scoped_children`). `False`
where a key of the block does not decode.
"""
if match.calls is None or match.scope_param_name is None:
return True
scope = match.scope_param_name
watched = frozenset({scope, *state})
keys: dict[Node, str] = {}
def decode(node: Node, hidden: frozenset[str]) -> bool:
if isinstance(node, FUNCTION_NODES):
return True
if (
isinstance(node, JsMemberExpression)
and node.computed
and (key := node.property) is not None
and not isinstance(key, JsStringLiteral)
and isinstance(holder := _scope_holder(node), JsIdentifier)
and holder.name == scope
and scope not in hidden
):
visible = {name: value for name, value in state.items() if name not in hidden}
if not isinstance(value := _eval_expr(key, _Env(visible, calls=match.calls)), str):
return False
keys[node] = value
for child, bound in _scoped_children(node):
if evaluated_later(node, child):
continue
if not decode(child, hidden | (watched & bound) if bound else hidden):
return False
return True
for original, clone in zip(originals, clones):
keys.clear()
if not decode(original, frozenset()):
return False
starts: dict[Node, Expression] = {}
for node in original.walk():
if (
isinstance(node, JsFunctionExpression)
and (call := _plain_wrapper_call(node, match)) is not None
and isinstance(entry := _eval_expr(call.arguments[0], _Env({}, calls=match.calls)), tuple)
and (literal := _array_literal(entry)) is not None
):
starts[call.arguments[0]] = literal
for node, copy in list(zip(original.walk(), clone.walk())):
if (value := keys.get(node)) is not None:
assert isinstance(copy, JsMemberExpression) and copy.property is not None
_replace_in_parent(copy.property, make_string_literal(value))
elif (literal := starts.get(node)) is not None:
_replace_in_parent(copy, literal)
return True
def _array_literal(values: tuple[float, ...]) -> JsArrayExpression | None:
"""
An array literal of the numbers *values*, or `None` where one of them has no literal.
"""
elements: list[Expression | None] = []
for value in values:
if (literal := make_numeric_literal(value)) is None:
return None
elements.append(literal)
return JsArrayExpression(elements=elements)
def _scope_holder(member: JsMemberExpression) -> Expression | None:
"""
The object an access `scope[k]` or `scope.P[k]` reads the scope object as.
"""
holder = member.object
if isinstance(holder, JsMemberExpression):
holder = holder.object
return holder
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_function_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_function_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:
while isinstance(statement, JsLabeledStatement):
statement = statement.body
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. Code that runs at a time of its own
(`refinery.lib.scripts.js.analysis.evaluation.evaluated_later`) is not entered, a nested
function at the root as well: a function reads the state variable when it is called, and the
initializer of a field written without `static` when an instance is made, not at the state
being recovered.
"""
if isinstance(node, FUNCTION_NODES):
return
for child, bound in _scoped_children(node):
if isinstance(child, FUNCTION_NODES) or evaluated_later(node, child):
continue
if (
isinstance(child, JsMemberExpression)
and isinstance(holder := child.object, JsIdentifier)
and isinstance(env.get(holder.name), tuple)
and holder.name not in bound
and not is_member_write_target(child)
and (value := _finite_number(_eval_expr(child, _Env(env)))) is not None
):
literal = make_numeric_literal(value)
if literal is not None:
_replace_in_parent(child, literal)
continue
if (
isinstance(child, JsIdentifier)
and child.name not in bound
and (held := env.get(child.name)) is not None
and not isinstance(held, tuple)
):
literal = make_numeric_literal(held)
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: _MachineMatch) -> 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)
exempt |= match.machine_names
for name in (
match.scope_param_name,
match.arg_var_name,
match.with_redirect_var,
):
if name is not None:
exempt.add(name)
if isinstance(match.flag, _FlagVariable):
exempt.add(match.flag.name)
return exempt
def _qualify_condition(
condition: Expression,
state: _StateEnv,
match: _MachineMatch,
) -> 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))
assert wrapper.expression is not None
_fold_scope_keys([condition], [wrapper.expression], state, match)
_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: _MachineMatch,
) -> 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)
class _RunReturns(NamedTuple):
"""
What the returns of the code of one run say: whether one returns without raising the flag
(`plain`), and whether one that raises it stands in a `try` statement (`intercepted`). The flag
is raised before the value of such a return is computed: the value may throw into a `catch`, and
a `finally` may end the run another way, so the run may go on with the flag raised.
"""
plain: bool
intercepted: bool
def _raises_the_flag(expr: Expression | None, flag: _Flag | None) -> bool:
"""
Whether *expr* is the store `didReturn = true` the obfuscator writes ahead of the value of a
return.
"""
return (
flag is not None
and isinstance(expr, JsAssignmentExpression)
and expr.operator == '='
and flag.spelled_by(expr.left)
and isinstance(expr.right, JsBooleanLiteral)
and expr.right.value is True
)
def _own_returns(
stmt: Statement,
name: str,
hidden: bool,
) -> Iterator[tuple[JsReturnStatement, bool, bool]]:
"""
Each `return` of the code *stmt* belongs to, not of a function inside it, with whether *name*
may mean another binding there than the one it means around *stmt*, and whether the return
stands in a `try` statement. It may where it does at *stmt* already (*hidden*), where something
between *stmt* and the return binds it anew (`_scoped_children`), and in the body of a `with`
statement, whose object may hold a property of that name.
"""
stack: list[tuple[Node, bool, bool]] = [(stmt, hidden, False)]
while stack:
node, hidden, guarded = stack.pop()
if isinstance(node, JsReturnStatement):
yield node, hidden, guarded
if isinstance(node, FUNCTION_NODES):
continue
guarded = guarded or isinstance(node, JsTryStatement)
scoped = node.body if isinstance(node, JsWithStatement) else None
stack.extend(
(child, hidden or name in bound or child is scoped, guarded)
for child, bound in reversed(list(_scoped_children(node)))
)
def _recover_returns(stmts: list[Statement], match: _MachineMatch) -> _RunReturns:
"""
Rewrite each return of the code of a run of the machine of *match* that raises the flag,
return didReturn = true, value;
to the return of its value, wherever in that code it stands; a function inside keeps its own
returns. A store under the flag's spelling where the generator binds its name for the code of
the switch (`_names_bound_for_the_switch`), or where something between binds it anew, stores to
that binding, and the return does not raise the flag.
"""
flag = match.flag
name = flag.base if flag is not None else ''
hidden = flag is not None and name in _names_bound_for_the_switch(match)
plain = intercepted = False
for stmt in stmts:
for node, hidden_here, guarded in list(_own_returns(stmt, name, hidden)):
argument = node.argument
if (
hidden_here
or not isinstance(argument, JsSequenceExpression)
or len(argument.expressions) < 2
or not _raises_the_flag(argument.expressions[0], flag)
):
plain = True
continue
intercepted = intercepted or guarded
value = (
argument.expressions[1] if len(argument.expressions) == 2
else JsSequenceExpression(expressions=argument.expressions[1:])
)
set_child(node, 'argument', value)
return _RunReturns(plain, intercepted)
def _names_bound_for_the_switch(match: _MachineMatch) -> set[str]:
"""
The names the generator of *match* binds for the code of the cases of its switch: its parameters
and its own name, what its body declares (`_function_bound_names`), and what the cases declare
for the block of the switch.
"""
statements = [
statement for case in match.switch_stmt.cases if isinstance(case, JsSwitchCase)
for statement in case.body
]
return _function_bound_names(match.machine) | _block_bound_names(statements)
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: _MachineMatch,
) -> 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
class _RunOrder:
"""
When the statements of the code recovered for one run run, read off the graph of the run: the
block whose payload each statement is and its position there (`homes`), the dominators of the
blocks, and the blocks each block reaches.
"""
def __init__(self, cfg: _CFG, idom: dict[int, int | None], homes: dict[Node, tuple[int, int]]):
self.cfg = cfg
self.idom = idom
self.homes = homes
self._reached: dict[int, set[int]] = {}
def _reaches(self, disc: int) -> set[int]:
"""
The blocks a way of at least one step from the block *disc* reaches.
"""
if (found := self._reached.get(disc)) is None:
found = set()
stack = list(self.cfg.nodes[disc].successors)
while stack:
if (succ := stack.pop()) in found or succ not in self.cfg.nodes:
continue
found.add(succ)
stack.extend(self.cfg.nodes[succ].successors)
self._reached[disc] = found
return found
def _home(self, node: Node) -> tuple[int, int] | None:
cursor: Node | None = node
while cursor is not None:
if (home := self.homes.get(cursor)) is not None:
return home
cursor = cursor.parent
return None
def replace(self, statement: Node, replacement: Sequence[Node]) -> None:
"""
Record that the statements of *replacement* stand where *statement* stood, and run when it
ran.
"""
if (home := self.homes.pop(statement, None)) is not None:
for stmt in replacement:
self.homes[stmt] = home
def runs_only_before(self, first: Node, then: Node) -> bool:
"""
Whether *first* runs before *then* on every way to it and never again once *then* has run,
both standing in the code of this run: the payload statement holding *first* comes earlier
in a block that holds both and that no loop holds, or its block dominates the block of
*then*, which does not reach it.
"""
if (early := self._home(first)) is None or (late := self._home(then)) is None:
return False
if early[0] == late[0]:
return early[1] < late[1] and early[0] not in self._reaches(early[0])
return _dominates(self.idom, early[0], late[0]) and early[0] not in self._reaches(late[0])
class _Run(NamedTuple):
"""
The code recovered for one run of the machine, what its routing slots hold once it has entered
the machine (`_build_cfg`), what its returns say (`_RunReturns`), and when its statements run
(`_RunOrder`).
"""
statements: list[Statement]
settled: _Slots
returns: _RunReturns
order: _RunOrder
def _execute_machine(
machine: _StateMachine,
match: _MachineMatch,
slots: _Slots,
main: bool,
) -> _Run | 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 run (`_Run`),
the *main* run or a wrapper's.
"""
entry = _Env(_apply_initial_state(match.state_var_names, match.initial_state), slots, match.calls)
cfg_result = _build_cfg(machine, entry, match, main)
if cfg_result is None:
return None
cfg, settled = cfg_result
idom = _compute_idom(cfg)
structuring = _Structuring(cfg, idom, match.machine_names)
if (stmts := structuring.run(_find_loops(cfg, idom))) is None:
return None
return _Run(
stmts,
settled,
_recover_returns(stmts, match),
_RunOrder(cfg, idom, structuring.homes),
)
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: _MachineMatch,
) -> _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: _MachineMatch,
main: _Activation,
main_order: _RunOrder,
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). A function that calls a name of
the machine, or hands on a `runtime`, which something in the recovered code binds anew, as a
machine nested in it under the same names does, calls that binding (`_references_to`) and is
no wrapper of this machine.
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`) whose stores
could leave the copy the wrapper's scope object holds apart from the creator's value, which the
recovered code can only share (`_stores_precede_the_wrappers`).
"""
gen_names = match.machine_names
named = gen_names if match.runtime_name is None else gen_names | {match.runtime_name}
activations: dict[int, _Activation] = {}
orders: dict[int, _RunOrder] = {}
entries: dict[int, list[_StateValue]] = {}
while True:
resolved_any = False
reads_of_the_machine = {id(read) for read in _references_to(stmts, named)}
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 = match.design.wrapper(node, match)
if wrapper_info is None or node.body is None:
continue
if not all(id(read) in reads_of_the_machine for read in wrapper_info.machine_reads):
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 = _MachineMatch(
machine=match.machine,
machine_names=gen_names,
design=match.design,
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,
flag=match.flag,
result_var=None,
machine_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,
runtime_name=match.runtime_name,
calls=match.calls,
)
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
orders[node_id] = result.order
entries[node_id] = wrapper_info.initial_state
recovered = result.statements
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, result.order)
resolved_any = True
if not resolved_any:
break
resolved = list({id(activation): activation for activation in activations.values()}.values())
if not _stores_precede_the_wrappers(stmts, activations, orders, main, main_order):
return None
return resolved
def _stores_precede_the_wrappers(
stmts: list[Statement],
activations: dict[int, _Activation],
orders: dict[int, _RunOrder],
main: _Activation,
main_order: _RunOrder,
) -> bool:
"""
Whether every store in *stmts* to a key a resolved wrapper hands on from the run that creates it
(`_Activation.inherited`) leaves what the wrapper's runs read as it was. A wrapper's scope
object holds a copy of the creator's value, made when the wrapper is called, which the recovered
code shares with the creator. They read the same as long as the run that holds the key as its
own stores to it only before it makes the wrapper that hands it on, directly or through
wrappers that hand it on again: the store and the statement making that wrapper both stand in
the own code of that run (`_own_run`), and the store runs before it on every way to it and never
again afterwards (`_RunOrder.runs_only_before`). The obfuscator stores the scope object of a
block that way, once, where the block starts, before the functions the block declares. A direct
`eval` can store to the key where no store is written, so none may stand in the code.
"""
wrappers = [
activation for activation in activations.values()
if activation.function is not None and activation.inherited
]
names = frozenset(name for activation in wrappers for name in activation.inherited)
if not names:
return True
if any(is_direct_eval_call(node) for stmt in stmts for node in stmt.walk()):
return False
for stmt in stmts:
for node, shadowed in _walk_scoped(stmt, names):
if not (
isinstance(node, JsIdentifier)
and node.name in names
and node.name not in shadowed
and is_reference(node)
and is_member_write_target(node)
):
continue
if (owner := _own_run(node, activations)) is None:
return False
for activation in wrappers:
if node.name not in activation.inherited:
continue
wrapper = activation.function
assert wrapper is not None
creator = _creating_activation(wrapper, activations, main)
while creator.function is not None and node.name in creator.inherited:
wrapper = creator.function
creator = _creating_activation(wrapper, activations, main)
if _own_run(wrapper, activations) != owner:
return False
order = main_order if owner == _MAIN_RUN else orders[owner]
if creator is not (main if owner == _MAIN_RUN else activations[owner]):
return False
if not order.runs_only_before(node, wrapper):
return False
return True
_MAIN_RUN = 0
"""
The key `_own_run` gives the code of the main run, which no wrapper's `id` can be.
"""
def _own_run(node: Node, activations: dict[int, _Activation]) -> int | None:
"""
The run whose own code holds *node*: the `id` of the innermost resolved wrapper whose body holds
it, or `_MAIN_RUN`; `None` where another function or a class body holds it first, whose code
runs whenever it is called or constructed.
"""
cursor: Node | None = node.parent
while cursor is not None:
if isinstance(cursor, FUNCTION_NODES):
return id(cursor) if id(cursor) in activations else None
if isinstance(cursor, JsClassBody):
return None
cursor = cursor.parent
return _MAIN_RUN
def _reenters(
node: JsFunctionExpression,
initial_state: list[_StateValue],
entries: dict[int, list[_StateValue]],
) -> 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 _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
)
class _ArgumentSlot(NamedTuple):
"""
A slot an argument store fills, and the default an element `slot = default` gives it.
"""
target: Expression
default: Expression | None
class _ArgumentPattern(NamedTuple):
"""
An array pattern an argument store unpacks into, `[e1, …, en]` or `[e1, …, ...r]`: its elements,
each a slot (`_ArgumentSlot`) or an array pattern of its own, the slot taking the remaining
elements where it ends in a rest element, and the default an element `[…] = default` of the
pattern around it gives it.
"""
elements: list[_ArgumentSlot | _ArgumentPattern]
remainder: Expression | None
default: Expression | None
@property
def nested(self) -> bool:
"""
Whether an element of the pattern is a pattern itself, which unpacks an argument.
"""
return any(isinstance(element, _ArgumentPattern) for element in self.elements)
def slots(self) -> Iterator[Expression]:
"""
The slots the pattern fills, in the order it fills them.
"""
for element in self.elements:
if isinstance(element, _ArgumentPattern):
yield from element.slots()
else:
yield element.target
if self.remainder is not None:
yield self.remainder
def defaulted(self) -> Iterator[tuple[Expression, int]]:
"""
Every default the pattern evaluates, in the order it evaluates them, each with the number
of slots filled before it: the default of an element is evaluated before that element's
own slots are filled.
"""
filled = 0
for element in self.elements:
if element.default is not None:
yield element.default, filled
if isinstance(element, _ArgumentPattern):
for default, before in element.defaulted():
yield default, filled + before
filled += sum(1 for _ in element.slots())
else:
filled += 1
def _argument_pattern(
pattern: JsArrayExpression | JsArrayPattern,
activation: _Activation,
default: Expression | None = None,
) -> _ArgumentPattern | None:
"""
The pattern an argument store `[e1, …, en] = rest` or `[e1, …, ...r] = rest` unpacks the
arguments into, with *default* as its own. Each element is a slot of *activation*'s own scope
object (`_is_activation_slot`) or an array pattern of such elements, as the store of a
flattened function's arguments, `[[NS.a, NS.b = d], NS.O] = rest`, has one. An element may
carry a default, `s = d`, and a pattern may end in a rest element whose target is a slot. `None`
for any other element, a hole among them.
"""
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
parts: list[_ArgumentSlot | _ArgumentPattern] = []
for element in elements:
target, value = element, None
if isinstance(element, JsAssignmentPattern):
target, value = element.left, element.right
if isinstance(target, (JsArrayExpression, JsArrayPattern)):
if (inner := _argument_pattern(target, activation, value)) is None:
return None
parts.append(inner)
elif target is not None and _is_activation_slot(target, activation):
parts.append(_ArgumentSlot(target, value))
else:
return None
return _ArgumentPattern(parts, remainder, default)
def _activation_slot_identity(node: Node, activation: _Activation) -> tuple[str, ...] | None:
"""
What *node* names of the scope object of *activation*: `(namespace, key)` for a member of a
namespace the object is created with, `(name,)` for a variable the run keeps on the object, `()`
for a namespace itself, and `None` for anything else.
"""
if isinstance(node, JsIdentifier):
if node.name in activation.namespaces:
return ()
if node.name in activation.props and node.name not in activation.inherited:
return (node.name,)
return None
if (
isinstance(node, JsMemberExpression)
and isinstance(node.object, JsIdentifier)
and node.object.name in activation.namespaces
and (key := access_key(node)) is not None
):
return (node.object.name, key)
return None
def _is_anonymous_definition(node: Node | None) -> bool:
"""
Whether *node* defines a function or a class without a name of its own, which a binding of a
plain name gives the name it binds: an arrow, or a function or class expression with no name.
"""
node = strip_parens(node)
if isinstance(node, JsArrowFunctionExpression):
return True
return isinstance(node, (JsFunctionExpression, JsClassExpression)) and node.id is None
def _default_reads(
default: Expression,
earlier: list[Expression],
function: JsFunctionExpression,
activation: _Activation,
) -> dict[Node, int] | None:
"""
Whether the default *default* of an element of the argument store can become the default of its
parameter, and the reads in it that become reads of an earlier parameter. A default reads the
elements before it, stored before it is evaluated, and where it reads one of them plainly, the
read becomes that parameter (`_unpack_argument_stores`). `None` where the default reads `this`,
a name the body binds, or any other part of what the run keeps on its scope object: its own
element or a later one, which the parameter list has not stored, a namespace as a whole, a
store to one, a call through one, which hands the namespace on as `this`, and a read under a
function, which reads it when called. `None` as well where the default holds a direct `eval`,
which the parameter list runs where no name of the body exists, and where it is a function or a
class without a name of its own, which a parameter names after itself and an element of the
store, a member, leaves unnamed (`_is_anonymous_definition`).
"""
if _is_anonymous_definition(default):
return None
bound = _function_bound_names(function)
slots = {
identity: index for index, target in enumerate(earlier)
if (identity := _activation_slot_identity(target, activation))
}
reads: dict[Node, int] = {}
def visit(node: Node, nested: bool) -> bool:
if isinstance(node, JsThisExpression) or is_direct_eval_call(node):
return False
if isinstance(node, JsIdentifier) and not is_reference(node):
return True
if isinstance(node, JsIdentifier) and node.name in bound:
return False
if (identity := _activation_slot_identity(node, activation)) is not None:
if (
nested
or not identity
or (index := slots.get(identity)) is None
or is_member_write_target(node)
or is_invocation_target(node)
):
return False
reads[node] = index
return True
return all(
visit(child, nested or evaluated_later(node, child)) for child in node.children()
)
return reads if visit(default, False) else None
def _unpack_argument_stores(
function: JsFunctionExpression,
activation: _Activation,
order: _RunOrder,
) -> 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 the statements `NS.a = a; NS.r = r;`: every slot receives what it received, the
remainder being an array of the same arguments. Each store is a statement of its own, where the
passes that drop a dead store or fold a read copy into its parameter reach it, and runs when the
store it replaces ran in the run of the wrapper (*order*, `_RunOrder.replace`). 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, and the list is the one
`refinery.lib.scripts.js.deobfuscation.parameters.parameter_list` writes. A parameter takes a
name no identifier and no static key of the function spells.
An element with a default, `[NS.a, NS.b = d] = rest`, gives its parameter that default where
`_default_reads` admits it: the default reads no `this`, no name the body binds, and of the
scope object only a plain read of a slot filled before it, which becomes a read of that slot's
parameter. The store
[NS.a, NS.b = NS.a] = rest;
gives the wrapper the parameters `(a, b = a)`. An element may also be an array pattern of such
elements, which unpacks one argument, as the store of a flattened function's arguments does:
`[[NS.a, NS.b = false], NS.O] = rest` gives the parameters `([a, b = false], O)`. A default runs
code and so does unpacking an argument, which the parameter list does at the call, so a store
holding either has to be the one store and the first statement of the body, and the wrapper no
generator, whose body runs only at its first step.
"""
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, _ArgumentPattern]] = []
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 (argument := _argument_pattern(pattern, activation)) is not None
):
return
stores.append((store, argument))
if not stores:
return
store, argument = max(stores, key=lambda entry: len(list(entry[1].slots())))
runs_code = argument.nested or any(argument.defaulted())
if len(stores) > 1 and any(
other.remainder is not None or other.nested or any(other.defaulted())
for _, other in stores
):
return
if runs_code and (
store.parent is not function.body.body[0]
or is_generator_function(function)
):
return
filled = list(argument.slots())
defaults: dict[Node, dict[Node, int]] = {}
for default, before in argument.defaulted():
if (reads := _default_reads(default, filled[:before], function, activation)) is None:
return
defaults[default] = reads
taken = {node.name for node in function.walk() if isinstance(node, JsIdentifier)}
taken.update(
key for node in function.walk()
if isinstance(node, JsMemberExpression) and (key := access_key(node)) is not None
)
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
filled_by = [list(other.slots()) for _, other in stores]
names = [
fresh(next(slots[index] for slots in filled_by if index < len(slots)))
for index in range(len(filled))
]
unbound = iter(names)
def value(default: Expression) -> Expression:
reads = defaults[default]
copied = _clone_node(default)
for node, copy in list(zip(default.walk(), copied.walk())):
if (slot := reads.get(node)) is None:
continue
if copy is copied:
copied = JsIdentifier(name=names[slot])
else:
_replace_in_parent(copy, JsIdentifier(name=names[slot]))
return copied
def parameter(part: _ArgumentSlot | _ArgumentPattern) -> Expression:
if isinstance(part, _ArgumentPattern):
bound: Expression = JsArrayPattern(elements=parameters_of(part))
else:
bound = JsIdentifier(name=next(unbound))
if part.default is None:
return bound
return JsAssignmentPattern(left=bound, right=value(part.default))
def parameters_of(pattern: _ArgumentPattern) -> list[Expression | None]:
elements: list[Expression | None] = [parameter(part) for part in pattern.elements]
if pattern.remainder is not None:
elements.append(JsRestElement(argument=JsIdentifier(name=next(unbound))))
return elements
parameters = parameter_list(parameters_of(argument), function, None)
if parameters is None:
return
for store, argument in stores:
assignments: list[Expression] = [
JsAssignmentExpression(operator='=', left=slot, right=JsIdentifier(name=name))
for slot, name in zip(argument.slots(), names)
]
statement = store.parent
assert statement is not None and function.body is not None
body = function.body.body
at = next(index for index, kept in enumerate(body) if kept is statement)
separate: list[Statement] = [
JsExpressionStatement(expression=assignment) for assignment in assignments
]
set_child_list(function.body, 'body', [*body[:at], *separate, *body[at + 1:]])
order.replace(statement, separate)
set_child_list(function, 'params', parameters.build())
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: _MachineMatch,
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: _MachineMatch) -> 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: _MachineMatch) -> 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: _MachineMatch,
trailing: list[Statement],
wrapped: bool,
returns: _RunReturns,
) -> 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,
and so does a return that raises the flag where the run may go on (`_RunReturns.intercepted`).
Without either, every return falls through, and `sanitize_inlined_body` writes the one at the
end as the expression it returns.
"""
if match.returns_value:
return None if trailing else recovered
if match.guarded:
if returns.plain or (trailing and (wrapped or returns.intercepted)):
return None
return recovered
return sanitize_inlined_body(recovered)
def _scaffolding_is_private(
model: SemanticModel,
body: list[Statement],
match: _MachineMatch,
absent: Callable[[str], bool],
) -> 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`), each a `var` of the
function holding the call, as the obfuscator writes them, or a function declaration. The flag
lives as long as one call of that function does: a variable it declares, or a property of an
object it makes for each call (`_property_flag_is_private`). A flag every call shares could be
raised by a call the recovered code makes before the guard reads it. A variable no store of
`undefined` resets right before the call holds `undefined` there only where the call runs once
for each call of the function declaring it: its statement stands in the body of that
function. Every name that spells `undefined` in the scaffolding has to denote it. No opaque
reflective surface, such as a direct `eval` in the code of the generator, may name one of its
parameters: the recovery moves that code out of the generator, where the name no longer exists
(`refinery.lib.scripts.js.analysis.model.SemanticModel.reflection_surface_sites`).
"""
if any(
(binding := model.binding_of(name)) is None or model.reflection_surface_sites(binding)
for param in match.machine.params
for name in pattern_identifiers(param)
):
return False
removed = match.removed(body)
inside = {id(node) for statement in removed for node in statement.walk()}
home = model.scope_of(body[match.scaffolding_end])
if (holder := _held_generator(removed[0])) is None or home is None:
return False
flag = match.flag
names = {match.result_var}
if isinstance(flag, _FlagVariable):
names.add(flag.name)
named = [
node for statement in removed[1:] for node in statement.walk()
if isinstance(node, JsIdentifier) and node.name in names
]
anchors: dict[str, Binding] = {}
for anchor in [holder[1], *named]:
binding = model.binding_of(anchor) or model.resolve(anchor)
if binding is None or binding.is_lexical:
return False
anchors.setdefault(anchor.name, binding)
if isinstance(flag, _FlagVariable):
if (binding := anchors.get(flag.name)) is None or not binding.declared_within(home.node):
return False
if not match.reset and not (
isinstance(owner := body[match.call_start].parent, JsBlockStatement)
and _is_a_function_body(owner)
and binding.scope.node is owner.parent
):
return False
elif isinstance(flag, _FlagProperty):
if not _property_flag_is_private(model, body, match, flag, home, inside, absent):
return False
if not all(_reached_only_from(model, binding, inside) for binding in anchors.values()):
return False
return all(
denoted_value(read, model, converts_uninterceptably) == (True, None)
for read in match.undefined_reads
)
def _property_flag_is_private(
model: SemanticModel,
body: list[Statement],
match: _MachineMatch,
flag: _FlagProperty,
home: Scope,
inside: set[int],
absent: Callable[[str], bool],
) -> bool:
"""
Whether the property *flag* is a variable of one call of the function whose scope is *home*,
which only the nodes whose ids are *inside* reach. Its holder is a `var` of that function,
declared once, with an empty object literal, by a statement of *body* ahead of the generator,
so it holds an object of its own whenever the call runs. Every reference to the holder reaches
that object as a namespace
(`refinery.lib.scripts.js.deobfuscation.namespaces.namespace_accesses`) under keys *absent*
proves no prototype supplies, so each of them is a property of data. What reaches the flag's
key, and every reflective surface that could name the holder, stands inside.
"""
references = (
node for statement in match.removed(body)[1:]
for node in statement.walk()
if isinstance(node, JsIdentifier) and node.name == flag.holder and is_reference(node)
)
anchor = next(references, None)
if anchor is None or (binding := model.binding_of(anchor) or model.resolve(anchor)) is None:
return False
if binding.scope is not home or binding.is_lexical:
return False
declarations = list(binding.declarations)
if len(declarations) != 1:
return False
declarator = declarations[0].parent
if not (
isinstance(declarator, JsVariableDeclarator)
and declarator.id is declarations[0]
and isinstance(declarator.init, JsObjectExpression)
and not declarator.init.properties
and any(
declarator.parent is statement
for statement in body[:match.call_start] if statement is not body[match.machine_index]
)
):
return False
accesses = namespace_accesses([*model.references(binding), *binding.dynamic_refs])
if accesses is None or not all(absent(key) for key in accesses):
return False
if not all(id(access) in inside for access in accesses.get(flag.key, ())):
return False
return all(id(node) in inside for node in model.reflection_surface_sites(binding))
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: _MachineMatch,
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 = match.removed(body)
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
if any(not home.contains(copy.var.scope, strict=True) for copy in binding.copies_made):
return False
return True
def _leaves_the_generator(recovered: list[Statement], match: _MachineMatch) -> bool:
"""
Whether *recovered* still refers to a binding the recovery removes: the generator under any of
its names, 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. A flag
that is a property of a variable is still referred to where the code reads or writes that
property.
"""
flag = match.flag
names = {
match.scope_param_name,
match.arg_var_name,
match.result_var,
match.runtime_name,
*match.state_var_names,
*match.machine_names,
}
if isinstance(flag, _FlagVariable):
names.add(flag.name)
names.discard(None)
if any(True for _ in _references_to(recovered, frozenset(names))):
return True
if not isinstance(flag, _FlagProperty):
return False
return any(
isinstance(access := node.parent, JsMemberExpression)
and access.object is node
and access_key(access) == flag.key
for node in _references_to(recovered, frozenset({flag.holder}))
)
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: _MachineMatch,
) -> 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 in match.machine_names
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: _MachineMatch) -> 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: _MachineMatch,
) -> 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: _MachineMatch,
) -> 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 _Calls:
"""
The calls of the program's own functions a run of the machine makes, run by the shared
admission (`refinery.lib.scripts.js.deobfuscation.admission.CallAdmission`) on the values the
run computes. It is asked only about a call of the machine being decided, a node of the tree
it holds, and answers each call once for each value of its arguments (`_value_key`). The
admission is built on the first question, over the models the cache holds then, and again
wherever the cache holds another effect model: what it derived from one tree is no answer about
another.
"""
def __init__(self, models: ModelCache, script: JsScript):
self._models = models
self._script = script
self._admission: CallAdmission | None = None
self._admitted: dict[Node, JsFunctionNode | None] = {}
self._memo: dict[tuple[object, ...], _Value | None] = {}
def _current(self) -> CallAdmission:
admission = self._admission
if admission is None or admission.effects is not self._models.effects:
self._admission = admission = CallAdmission(self._models, self._script)
self._admitted.clear()
self._memo.clear()
return admission
def iterates_arrays(self, node: Node) -> bool:
"""
Whether a spread of an array at *node* yields its elements: the prototype chain of an array
is intact there, the question a `for-of` walk of the interpreter asks.
"""
trust = self._current().trust_at(node)
return trust is not None and trust.chain(list)
def __call__(self, node: JsCallExpression, arguments: list[_Value]) -> _Value | None:
"""
The value the call *node* gives with *arguments*, or `None` where the admission does not
run it, an argument is `undefined`, the run is refused or throws, or its result is no value
a run of the machine holds: a number, a boolean, a string, or an array of numbers, which a
Buffer is not. An array argument is the state of the machine or an array made from it, and
the helper is handed a copy: two array arguments may be one object, which two copies are
not, so such a call is not run, and a helper that changes the array it is handed changes
the state, which the run does not follow (`_left_as_handed`), so its value is not taken.
"""
admission = self._current()
if not admission.holds(node):
return None
if sum(isinstance(argument, tuple) for argument in arguments) > 1:
return None
values: list[Value] = []
for argument in arguments:
if isinstance(argument, _Undefined):
return None
values.append(list(argument) if isinstance(argument, tuple) else argument)
if node not in self._admitted:
self._admitted[node] = admission.callee(node)
if (function := self._admitted[node]) is None:
return None
key = (node, *map(_value_key, arguments))
if key in self._memo:
return self._memo[key]
try:
result = admission.interpreter_at(node).execute(function, values)
except (InterpreterError, IrreducibleExpression, _ThrowSignal):
result = None
value: _Value | None
if not all(map(_left_as_handed, values, arguments)):
value = None
elif isinstance(result, (bool, str)):
value = result
elif isinstance(result, (int, float)):
value = float(result)
elif (
type(result) is list
and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in result)
):
value = tuple(float(x) for x in result)
else:
value = None
self._memo[key] = value
return value
def _left_as_handed(handed: Value, argument: _Value) -> bool:
"""
Whether a run of a helper left *handed*, what it was given for *argument*, as it was given: an
array still holds the same numbers.
"""
if not isinstance(argument, tuple):
return True
return (
isinstance(handed, list)
and len(handed) == len(argument)
and all(
isinstance(x, (int, float)) and not isinstance(x, bool) and _same_value(float(x), y)
for x, y in zip(handed, argument)
)
)
class _Design:
"""
What one design of the flattening spells its own way: the machine and the call that runs it
(`match`), the state that call starts in (`read_start`), and the call a wrapper of the machine
makes (`wrapper`). Blocks, transitions, structuring, returns, wrappers and the checks that the
scaffolding is private are shared.
"""
runs_helpers: bool = False
"""
Whether the values of a run are made by calls of the program's own functions, which the shared
admission runs (`_Calls`).
"""
def match(self, body: list[Statement], idx: int) -> _MachineMatch | None:
"""
The machine of this design declared at index *idx* of *body*, with its call site.
"""
raise NotImplementedError
def read_start(self, match: _MachineMatch) -> bool:
"""
Read the state the call of the machine starts in into *match*; `False` where it is not
known.
"""
return True
def wrapper(self, node: JsFunctionExpression, match: _MachineMatch) -> _WrapperFunctionInfo | None:
"""
The call of the machine *node* makes where it is a wrapper of the machine of *match*.
"""
raise NotImplementedError
class _GeneratorDesign(_Design):
"""
A generator whose state is several variables, run by `M(…).next().value`; the matcher reads
the state it starts in from the numbers of its call.
"""
def match(self, body: list[Statement], idx: int) -> _MachineMatch | None:
return _match_generator_cff(body, idx, self)
def wrapper(self, node: JsFunctionExpression, match: _MachineMatch) -> _WrapperFunctionInfo | None:
return _detect_wrapper_function(node, match.machine_names, len(match.state_var_names))
class _ArrayDesign(_Design):
"""
A plain function whose state is one array of numbers, summed by a function of the program, run
by `M(array)`. The array of the call is evaluated with the call as the anchor of every question
the run asks, and only once the match is decided on: what it calls is run by the shared
admission, which the pass builds for the tree its held models were built over.
"""
runs_helpers = True
def match(self, body: list[Statement], idx: int) -> _MachineMatch | None:
return _match_array_machine(body, idx, self)
def read_start(self, match: _MachineMatch) -> bool:
entry = _eval_expr(match.initial_args[0], _Env({}, calls=match.calls))
if not isinstance(entry, tuple):
return False
match.initial_state = [entry]
return True
def wrapper(self, node: JsFunctionExpression, match: _MachineMatch) -> _WrapperFunctionInfo | None:
return _detect_plain_wrapper(node, match)
_DESIGNS: tuple[_Design, ...] = (_GeneratorDesign(), _ArrayDesign())
class JsStateMachineUnflattening(PinnedTransformer, BodyProcessingTransformer):
"""
Recover original code from functions flattened into a state machine: the body of a function is
replaced by a call of a machine function, a `while`/`switch` loop whose discriminant is the sum
of its state. Two designs write the machine (`_Design`): a generator function whose state is
several variables, and a plain function whose state is one array of numbers, summed by a
function of the program, whose values the program's own functions compute (`_Calls`).
The pass holds the models it reads for an invocation
(`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`): the semantic model, the
intrinsic-write scan, and the effect, dominance and tampering models the shared admission of
calls is built over. 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.
- Whether a call the machine makes may be run, and what it gives (`_Calls`), is asked only
about a call of the machine being decided, on a node of the tree the held models were built
over: before it builds the admission, the pass renews where it edited since the last
renewal. A recovery puts copies of the machine's own blocks where its call stood, so a call
or a store of a built-in the held models know of runs where it ran.
"""
reads = ('_model', '_intrinsic_writes', '_effects', '_dominance', '_tampering')
def __init__(self):
super().__init__()
self._edited: dict[Node, list[Node]] = {}
self._calls: _Calls | None = None
def visit_JsScript(self, node: JsScript):
with self.holding_models(node):
return super().visit_JsScript(node)
def _forget_edits(self) -> None:
self._edited = {}
self._calls = None
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 = next((m for design in _DESIGNS if (m := design.match(body, i))), None)
if match is None:
i += 1
continue
design = match.design
models = self.held_models()
removed = match.removed(body)
if (
self._edited_around(parent, removed)
or design.runs_helpers and self._calls is None and self._edited
):
self.renew_models()
if design.runs_helpers:
if self._calls is None:
root = tree_root(parent)
assert isinstance(root, JsScript)
self._calls = _Calls(self.held_models(), root)
match.calls = self._calls
if not design.read_start(match):
i += 1
continue
model = models.model
if _an_observed_copy_may_not_be_made(model, match.machine):
i += 1
continue
if not _scaffolding_is_private(model, body, match, self._provably_absent):
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 = result.statements
main = _Activation(
None,
match.scope_default_inits,
props=match.scope_prop_names,
routing=result.settled,
)
wrappers = _resolve_shared_wrappers(
recovered, machine, match, main, result.order, self._provably_absent
)
if (
wrappers is None
or not _bind_main_arguments(recovered, match)
or not _slots_may_become_variables(match, wrappers, self._provably_absent)
):
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),
result.returns,
)
if settled is None:
i += 1
continue
recovered = settled
for s in recovered:
s.parent = parent
start = match.machine_index
end = match.scaffolding_end
reveals = _recovery_reveals(model, models.intrinsic_writes, removed, recovered, match)
between = body[start + 1:match.call_start]
replacement = body[:start] + between + recovered + body[end + 1:]
self._replace_body(parent, replacement)
self._note_edit(parent)
if reveals:
self.renew_models()
i = start + len(between) + len(recovered)
Classes
class JsStateMachineUnflattening-
Recover original code from functions flattened into a state machine: the body of a function is replaced by a call of a machine function, a
while/switchloop whose discriminant is the sum of its state. Two designs write the machine (_Design): a generator function whose state is several variables, and a plain function whose state is one array of numbers, summed by a function of the program, whose values the program's own functions compute (_Calls).The pass holds the models it reads for an invocation (
PinnedTransformer): the semantic model, the intrinsic-write scan, and the effect, dominance and tampering models the shared admission of calls is built over. 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. - Whether a call the machine makes may be run, and what it gives (
_Calls), is asked only about a call of the machine being decided, on a node of the tree the held models were built over: before it builds the admission, the pass renews where it edited since the last renewal. A recovery puts copies of the machine's own blocks where its call stood, so a call or a store of a built-in the held models know of runs where it ran.
Expand source code Browse git
class JsStateMachineUnflattening(PinnedTransformer, BodyProcessingTransformer): """ Recover original code from functions flattened into a state machine: the body of a function is replaced by a call of a machine function, a `while`/`switch` loop whose discriminant is the sum of its state. Two designs write the machine (`_Design`): a generator function whose state is several variables, and a plain function whose state is one array of numbers, summed by a function of the program, whose values the program's own functions compute (`_Calls`). The pass holds the models it reads for an invocation (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`): the semantic model, the intrinsic-write scan, and the effect, dominance and tampering models the shared admission of calls is built over. 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. - Whether a call the machine makes may be run, and what it gives (`_Calls`), is asked only about a call of the machine being decided, on a node of the tree the held models were built over: before it builds the admission, the pass renews where it edited since the last renewal. A recovery puts copies of the machine's own blocks where its call stood, so a call or a store of a built-in the held models know of runs where it ran. """ reads = ('_model', '_intrinsic_writes', '_effects', '_dominance', '_tampering') def __init__(self): super().__init__() self._edited: dict[Node, list[Node]] = {} self._calls: _Calls | None = None def visit_JsScript(self, node: JsScript): with self.holding_models(node): return super().visit_JsScript(node) def _forget_edits(self) -> None: self._edited = {} self._calls = None 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 = next((m for design in _DESIGNS if (m := design.match(body, i))), None) if match is None: i += 1 continue design = match.design models = self.held_models() removed = match.removed(body) if ( self._edited_around(parent, removed) or design.runs_helpers and self._calls is None and self._edited ): self.renew_models() if design.runs_helpers: if self._calls is None: root = tree_root(parent) assert isinstance(root, JsScript) self._calls = _Calls(self.held_models(), root) match.calls = self._calls if not design.read_start(match): i += 1 continue model = models.model if _an_observed_copy_may_not_be_made(model, match.machine): i += 1 continue if not _scaffolding_is_private(model, body, match, self._provably_absent): 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 = result.statements main = _Activation( None, match.scope_default_inits, props=match.scope_prop_names, routing=result.settled, ) wrappers = _resolve_shared_wrappers( recovered, machine, match, main, result.order, self._provably_absent ) if ( wrappers is None or not _bind_main_arguments(recovered, match) or not _slots_may_become_variables(match, wrappers, self._provably_absent) ): 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), result.returns, ) if settled is None: i += 1 continue recovered = settled for s in recovered: s.parent = parent start = match.machine_index end = match.scaffolding_end reveals = _recovery_reveals(model, models.intrinsic_writes, removed, recovered, match) between = body[start + 1:match.call_start] replacement = body[:start] + between + recovered + body[end + 1:] self._replace_body(parent, replacement) self._note_edit(parent) if reveals: self.renew_models() i = start + len(between) + 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 (