Module refinery.lib.scripts.js.deobfuscation.interpreter
Mini-interpreter for executing pure JavaScript functions with concrete arguments.
Expand source code Browse git
"""
Mini-interpreter for executing pure JavaScript functions with concrete arguments.
"""
from __future__ import annotations
import base64
import json
import math
import re
import sys
import urllib.parse
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Callable, Mapping
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.tampering import TamperingModel
from refinery.lib.scripts.js.deobfuscation.helpers import Value
from refinery.lib.scripts import Node
from refinery.lib.scripts.js.analysis.effects import object_sets_prototype
from refinery.lib.scripts.js.analysis.environment import HostEnvironment, typeof_of_global
from refinery.lib.scripts.js.analysis.model import (
SemanticModel,
call_supplies_an_arguments_object,
statement_list_holding,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
GLOBAL_VALUE_NAMES,
JS_HOLE,
JS_NULL,
LOGICAL_ASSIGNMENT_OPS,
PROTO_KEY,
RELATIONAL_OPS,
SEQUENCE_DATA_PROPERTIES,
UNARY_OPS,
JsBuffer,
MemberRead,
_array_element_string,
_js_pow,
_to_int,
_to_int32,
_to_uint32,
canonical_array_index,
code_points,
coerces_uninterceptably,
coerces_uninterceptably_from_written_chain,
eval_binary_op,
js_typeof,
name_is_unbound,
names_global_value,
own_property_keys,
property_absent_from_written_chain,
property_is_inherited_from_an_intact_chain,
property_is_inherited_from_an_unwritten_chain,
property_provably_absent,
read_data_property,
spell_astral_characters,
to_boolean,
to_number,
to_string,
utf16_code_units,
walk_scope,
)
from refinery.lib.scripts.js.model import (
JsArrayExpression,
JsArrowFunctionExpression,
JsAssignmentExpression,
JsBinaryExpression,
JsBlockStatement,
JsBooleanLiteral,
JsBreakStatement,
JsCallExpression,
JsConditionalExpression,
JsContinueStatement,
JsDoWhileStatement,
JsEmptyStatement,
JsExpressionStatement,
JsForInStatement,
JsForOfStatement,
JsForStatement,
JsFunctionDeclaration,
JsFunctionExpression,
JsFunctionNode,
JsIdentifier,
JsIfStatement,
JsLogicalExpression,
JsMemberExpression,
JsNullLiteral,
JsNumericLiteral,
JsObjectExpression,
JsParenthesizedExpression,
JsProperty,
JsPropertyKind,
JsReturnStatement,
JsSequenceExpression,
JsStringLiteral,
JsSwitchCase,
JsSwitchStatement,
JsTemplateLiteral,
JsThrowStatement,
JsTryStatement,
JsUnaryExpression,
JsUpdateExpression,
JsVariableDeclaration,
JsVariableDeclarator,
JsVarKind,
JsWhileStatement,
wraps_return,
)
from refinery.lib.scripts.js.numbers import (
TRIMMABLE_WHITESPACE,
js_parse_float,
js_parse_int,
)
from refinery.lib.scripts.js.token import ASCII_WHITESPACE
from refinery.lib.scripts.js.utf16 import to_code_units
MAX_ITERATIONS = 100_000
MAX_STRING_LEN = 1_000_000
MAX_ARRAY_LEN = 1_000_000
_MAX_RECURSION = 10
class InterpreterError(Exception):
pass
class IrreducibleExpression(Exception):
def __init__(self, node: Node):
self.node = node
class _ReturnSignal(Exception):
def __init__(self, value: Value):
self.value = value
class _BreakSignal(Exception):
pass
class JsArguments:
"""
The `arguments` object of the call being interpreted: the argument values that call was made
with, held read-only. Deliberately not a `list` subclass — nothing that dispatches on a list
may answer for it, no builtin registry entry exists for the type, and the object this models
exposes only `length` and the indexed elements. It cannot escape into a folded value: a body
whose `arguments` reads are not all element-wise never passes the preflight that admits one,
so no bare `arguments` ever reaches the value domain.
"""
__slots__ = ('values',)
def __init__(self, values: list[Value]):
self.values = values
class _ContinueSignal(Exception):
pass
class _ThrowSignal(Exception):
def __init__(self, value: Value):
self.value = value
def _js_throw(name: str, message: str = '') -> None:
"""
Signal a genuine JavaScript runtime exception (e.g. a `TypeError` or `RangeError`) that an
emulated `try/catch` must be able to catch. The thrown value is a plain object carrying `name`
and `message`, so `typeof e` is `'object'` and `e.name` / `e.message` are usable. This is
distinct from `InterpreterError`, which means "abort interpretation" and is never caught.
"""
raise _ThrowSignal({'name': name, 'message': message})
class _ReturnIrreducible(Exception):
"""
Raised when a function's return value (or an arrow's tail expression) is an irreducible
expression. This is distinct from a bare `IrreducibleExpression`, which may surface from a
non-return position (a variable initializer, an expression statement, a loop) and therefore does
NOT represent the function's value. Only a `_ReturnIrreducible` is converted back into an
`IrreducibleExpression` for the evaluator to substitute at the call site.
"""
def __init__(self, node: Node):
self.node = node
def _deep_copy_value(value):
if isinstance(value, list):
return type(value)(_deep_copy_value(item) for item in value)
if isinstance(value, dict):
return {k: _deep_copy_value(v) for k, v in value.items()}
return value
def _to_index(value: Value) -> int:
n = to_number(value)
if n != n:
return 0
if n == float('inf'):
return sys.maxsize
if n == float('-inf'):
return -sys.maxsize
return int(n)
def _to_array_length(value: Value) -> int:
"""
Coerce a value to a valid array length. Per ECMA-262 ArraySetLength, `ToUint32(v)` must equal
`ToNumber(v)`; otherwise the length is invalid and a JavaScript `RangeError` is signalled. This
rejects NaN, +/-Infinity, negative, and non-integer lengths, each of which a real engine
reports as `Invalid array length`.
"""
number_len = to_number(value)
length = _to_uint32(number_len)
if length != number_len:
_js_throw('RangeError', 'Invalid array length')
return length
#: The value-domain types that model a JavaScript object: an array, a plain object, and a function.
#: They share the two behaviours the primitives do not — a ToPrimitive that runs a (here
#: interceptable) conversion, and an equality by reference rather than by value. `_to_primitive` and
#: `_loose_equal` name the set once here rather than each enumerating it and drifting apart.
_OBJECT_VALUE_TYPES = (
list, dict, JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression,
)
def _to_primitive(value: Value) -> Value:
"""
Replicate the ECMA-262 ToPrimitive abstract operation with the default hint, as used by `+`.
Arrays, plain objects and functions have no useful `valueOf`, so they coerce to their string
form; all other values are already primitive.
"""
if isinstance(value, _OBJECT_VALUE_TYPES):
return to_string(value)
return value
def js_strict_equal(a: Value, b: Value) -> bool:
"""
Compare two interpreter values using JavaScript strict-equality (`===`) semantics. Unlike
Python equality this does not conflate booleans with the numbers `1` and `0`.
"""
if isinstance(a, bool) or isinstance(b, bool):
return a is b
if a is None or b is None:
return a is None and b is None
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a == b
if type(a) is not type(b):
return False
if isinstance(a, str):
return a == b
return a is b
BUILTIN_REGISTRY: dict[tuple, Callable] = {}
def _in_code_units(fn: Callable) -> Callable:
"""
Wrap a builtin so a bare string it produces is held as UTF-16 code units before it enters the
value domain, so a decode that introduces an astral code point yields the surrogate pair a
literal already is. Every reader of the registry — the interpreter and the simplifier — gets this
without repeating the rule, and a builtin added later is covered by being registered. It reaches
only a string the builtin returns directly: a string nested in a returned list or dict is the
producer's own to normalize (as `_json_to_value` does its keys and values). It is idempotent,
because `to_code_units` leaves a string already in code units untouched, and a no-op on any
non-string result.
"""
def in_code_units(*args):
result = fn(*args)
if isinstance(result, str):
return to_code_units(result)
return result
return in_code_units
def _register(key: tuple):
def _decorator(fn: Callable):
BUILTIN_REGISTRY[key] = _in_code_units(fn)
return fn
return _decorator
@_register((str, 'charAt'))
def _str_char_at(s: str, args: list[Value]) -> Value:
idx = _to_index(args[0]) if args else 0
if 0 <= idx < len(s):
return s[idx]
return ''
@_register((str, 'charCodeAt'))
def _str_char_code_at(s: str, args: list[Value]) -> Value:
idx = _to_index(args[0]) if args else 0
if 0 <= idx < len(s):
return ord(s[idx])
return float('nan')
@_register((str, 'indexOf'))
def _str_index_of(s: str, args: list[Value]) -> Value:
if not args:
return -1
search = to_string(args[0])
start = _to_index(args[1]) if len(args) > 1 else 0
return s.find(search, max(0, start))
@_register((str, 'lastIndexOf'))
def _str_last_index_of(s: str, args: list[Value]) -> Value:
if not args:
return -1
search = to_string(args[0])
n = len(s)
if len(args) > 1:
pos = to_number(args[1])
start = n if pos != pos else max(0, min(_to_index(args[1]), n))
else:
start = n
return s.rfind(search, 0, start + len(search))
@_register((str, 'includes'))
def _str_includes(s: str, args: list[Value]) -> Value:
if not args:
return False
search = to_string(args[0])
start = _to_index(args[1]) if len(args) > 1 else 0
return s.find(search, max(0, start)) != -1
@_register((str, 'startsWith'))
def _str_starts_with(s: str, args: list[Value]) -> Value:
if not args:
return False
prefix = to_string(args[0])
start = max(0, _to_index(args[1])) if len(args) > 1 else 0
return s[start:].startswith(prefix)
@_register((str, 'endsWith'))
def _str_ends_with(s: str, args: list[Value]) -> Value:
if not args:
return False
suffix = to_string(args[0])
end = max(0, min(_to_index(args[1]), len(s))) if len(args) > 1 else len(s)
return s[:end].endswith(suffix)
@_register((str, 'slice'))
def _str_slice(s: str, args: list[Value]) -> Value:
n = len(s)
start = _to_index(args[0]) if args else 0
end = _to_index(args[1]) if len(args) > 1 else n
if start < 0:
start = max(n + start, 0)
if end < 0:
end = max(n + end, 0)
return s[start:end]
@_register((str, 'substring'))
def _str_substring(s: str, args: list[Value]) -> Value:
n = len(s)
start = _to_index(args[0]) if args else 0
end = _to_index(args[1]) if len(args) > 1 else n
start = max(0, min(start, n))
end = max(0, min(end, n))
if start > end:
start, end = end, start
return s[start:end]
@_register((str, 'substr'))
def _str_substr(s: str, args: list[Value]) -> Value:
n = len(s)
start = _to_index(args[0]) if args else 0
length = _to_index(args[1]) if len(args) > 1 else n
if start < 0:
start = max(n + start, 0)
return s[start:start + max(0, length)]
@_register((str, 'split'))
def _str_split(s: str, args: list[Value]) -> Value:
if not args or args[0] is None:
if len(args) > 1 and args[1] is not None:
if _to_index(args[1]) == 0:
return []
return [s]
sep = to_string(args[0])
if not sep:
result = utf16_code_units(s)
else:
result = s.split(sep)
if len(args) > 1 and args[1] is not None:
limit = _to_uint32(to_number(args[1]))
result = result[:limit]
return result
def _expand_replacement(replacement: str, s: str, start: int, matched: str) -> str:
"""
Expand the JavaScript replacement-string patterns ($$, $&, $`, $') for a literal-string match
of `matched` at index `start` in `s`. Capture-group patterns ($1..) have no meaning for a
string search and are emitted verbatim, as JavaScript does.
"""
out: list[str] = []
i = 0
n = len(replacement)
while i < n:
c = replacement[i]
if c == '$' and i + 1 < n:
nxt = replacement[i + 1]
if nxt == '$':
out.append('$')
elif nxt == '&':
out.append(matched)
elif nxt == '`':
out.append(s[:start])
elif nxt == "'":
out.append(s[start + len(matched):])
else:
out.append('$')
out.append(nxt)
i += 2
continue
out.append(c)
i += 1
return ''.join(out)
@_register((str, 'replace'))
def _str_replace(s: str, args: list[Value]) -> Value:
if len(args) < 2:
return s
search = to_string(args[0])
replacement = to_string(args[1])
index = s.find(search)
if index < 0:
return s
expanded = _expand_replacement(replacement, s, index, search)
return s[:index] + expanded + s[index + len(search):]
@_register((str, 'replaceAll'))
def _str_replace_all(s: str, args: list[Value]) -> Value:
if len(args) < 2:
return s
search = to_string(args[0])
replacement = to_string(args[1])
if not search:
raise InterpreterError
out: list[str] = []
pos = 0
while True:
index = s.find(search, pos)
if index < 0:
out.append(s[pos:])
break
out.append(s[pos:index])
out.append(_expand_replacement(replacement, s, index, search))
pos = index + len(search)
return ''.join(out)
@_register((str, 'toLowerCase'))
def _str_to_lower(s: str, args: list[Value]) -> Value:
return spell_astral_characters(s).lower()
@_register((str, 'toUpperCase'))
def _str_to_upper(s: str, args: list[Value]) -> Value:
return spell_astral_characters(s).upper()
@_register((str, 'trim'))
def _str_trim(s: str, args: list[Value]) -> Value:
return s.strip(TRIMMABLE_WHITESPACE)
@_register((str, 'trimStart'))
def _str_trim_start(s: str, args: list[Value]) -> Value:
return s.lstrip(TRIMMABLE_WHITESPACE)
@_register((str, 'trimEnd'))
def _str_trim_end(s: str, args: list[Value]) -> Value:
return s.rstrip(TRIMMABLE_WHITESPACE)
@_register((str, 'repeat'))
def _str_repeat(s: str, args: list[Value]) -> Value:
count = _to_index(args[0]) if args else 0
if count < 0 or count > 0x10000000:
_js_throw('RangeError', 'Invalid count value')
return s * count
def _str_pad(s: str, args: list[Value], prepend: bool) -> Value:
target_len = _to_index(args[0]) if args else 0
if target_len > 0x10000000:
_js_throw('RangeError', 'Invalid string length')
fill = to_string(args[1]) if len(args) > 1 else ' '
needed = target_len - len(s)
if needed <= 0 or not fill:
return s
pad = (fill * (needed // len(fill) + 1))[:needed]
return pad + s if prepend else s + pad
@_register((str, 'padStart'))
def _str_pad_start(s: str, args: list[Value]) -> Value:
return _str_pad(s, args, prepend=True)
@_register((str, 'padEnd'))
def _str_pad_end(s: str, args: list[Value]) -> Value:
return _str_pad(s, args, prepend=False)
@_register((str, 'at'))
def _str_at(s: str, args: list[Value]) -> Value:
idx = _to_index(args[0]) if args else 0
if idx < 0:
idx += len(s)
if 0 <= idx < len(s):
return s[idx]
return None
@_register(('String', 'fromCharCode'))
def _string_from_char_code(args: list[Value]) -> Value:
return ''.join(chr(_to_int(a) & 0xFFFF) for a in args)
def _json_to_value(value):
"""
The interpreter's value for a node `json.loads` decoded: a JSON `null` (Python `None`) becomes
the `JS_NULL` sentinel rather than `undefined`, and every string — an object key as much as a
value — becomes the UTF-16 code units the value domain counts, so an astral character
`json.loads` read as one code point is the surrogate pair a literal already is. It recurses, so
a string nested in an object or array is reached too.
"""
if value is None:
return JS_NULL
if isinstance(value, str):
return to_code_units(value)
if isinstance(value, list):
return [_json_to_value(v) for v in value]
if isinstance(value, dict):
return {to_code_units(k): _json_to_value(v) for k, v in value.items()}
return value
@_register(('JSON', 'parse'))
def _json_parse(args: list[Value]) -> Value:
if not args:
raise InterpreterError
def _reject_constant(_: str) -> Value:
raise InterpreterError
s = to_string(args[0])
try:
parsed = json.loads(s, parse_int=float, parse_constant=_reject_constant)
except Exception:
raise InterpreterError
return _json_to_value(parsed)
@_register((list, 'push'))
def _arr_push(arr: list, args: list[Value]) -> Value:
arr.extend(args)
return len(arr)
@_register((list, 'pop'))
def _arr_pop(arr: list, args: list[Value]) -> Value:
if arr:
return _element_read(arr.pop())
return None
@_register((list, 'shift'))
def _arr_shift(arr: list, args: list[Value]) -> Value:
if arr:
return _element_read(arr.pop(0))
return None
@_register((list, 'unshift'))
def _arr_unshift(arr: list, args: list[Value]) -> Value:
for i, a in enumerate(args):
arr.insert(i, a)
return len(arr)
@_register((list, 'reverse'))
def _arr_reverse(arr: list, args: list[Value]) -> Value:
arr.reverse()
return arr
@_register((list, 'concat'))
def _arr_concat(arr: list, args: list[Value]) -> Value:
result = list(arr)
for a in args:
if isinstance(a, list):
result.extend(a)
else:
result.append(a)
return result
@_register((list, 'slice'))
def _arr_slice(arr: list, args: list[Value]) -> Value:
n = len(arr)
start = _to_index(args[0]) if args else 0
end = _to_index(args[1]) if len(args) > 1 else n
if start < 0:
start = max(n + start, 0)
if end < 0:
end = max(n + end, 0)
return arr[start:end]
@_register((list, 'splice'))
def _arr_splice(arr: list, args: list[Value]) -> Value:
if not args:
return []
start = _to_index(args[0])
n = len(arr)
if start < 0:
start = max(n + start, 0)
else:
start = min(start, n)
delete_count = _to_index(args[1]) if len(args) > 1 else n - start
delete_count = max(0, min(delete_count, n - start))
removed = arr[start:start + delete_count]
new_items = list(args[2:])
arr[start:start + delete_count] = new_items
return removed
@_register((list, 'join'))
def _arr_join(arr: list, args: list[Value]) -> Value:
sep = ',' if not args or args[0] is None else to_string(args[0])
return sep.join(_array_element_string(v) for v in arr)
@_register((list, 'toString'))
def _arr_to_string(arr: list, args: list[Value]) -> Value:
return to_string(arr)
@_register((int, 'toString'))
@_register((float, 'toString'))
def _number_to_string(num: int | float, args: list[Value]) -> Value:
radix = _to_int(args[0]) if args and args[0] is not None else 10
if radix == 10:
return to_string(num)
if not 2 <= radix <= 36:
_js_throw('RangeError', 'toString() radix must be between 2 and 36')
value = to_number(num)
if value != value or math.isinf(value):
return to_string(value)
if value != int(value):
raise InterpreterError
integer = abs(int(value))
if integer == 0:
return '0'
digits = '0123456789abcdefghijklmnopqrstuvwxyz'
out: list[str] = []
while integer:
out.append(digits[integer % radix])
integer //= radix
text = ''.join(reversed(out))
return '-' + text if value < 0 else text
@_register((list, 'indexOf'))
def _arr_index_of(arr: list, args: list[Value]) -> Value:
if not args:
return -1
target = args[0]
start = _to_index(args[1]) if len(args) > 1 else 0
if start < 0:
start = max(0, len(arr) + start)
for i in range(start, len(arr)):
if js_strict_equal(arr[i], target):
return i
return -1
def _element_read(value: Value) -> Value:
"""
The value an element-reading array method answers for *value*: a hole reads as `undefined`,
the same value the array's own iterator yields for it, and every other element is itself.
"""
return None if value is JS_HOLE else value
def _same_value_zero(a: Value, b: Value) -> bool:
"""
Compare two interpreter values with SameValueZero, the equality `includes` uses: strict
equality, except that two NaNs are equal — and a hole reads as the `undefined` the
iteration that visits it would yield.
"""
a = _element_read(a)
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
if a != a and b != b:
return True
return js_strict_equal(a, b)
@_register((list, 'includes'))
def _arr_includes(arr: list, args: list[Value]) -> Value:
if not args:
return False
start = _to_index(args[1]) if len(args) > 1 else 0
if start < 0:
start = max(0, len(arr) + start)
return any(_same_value_zero(item, args[0]) for item in arr[start:])
@_register((list, 'flat'))
def _arr_flat(arr: list, args: list[Value]) -> Value:
depth = _to_index(args[0]) if args else 1
def _flatten(lst: list, d: int) -> list:
result: list = []
for item in lst:
# Flattening asks whether the array has a property at each index, at every level,
# so a hole is skipped there rather than read or carried into the result.
if item is JS_HOLE:
continue
if isinstance(item, list) and d > 0:
result.extend(_flatten(item, d - 1))
else:
result.append(item)
return result
return _flatten(arr, depth)
@_register((list, 'at'))
def _arr_at(arr: list, args: list[Value]) -> Value:
idx = _to_index(args[0]) if args else 0
if idx < 0:
idx += len(arr)
if 0 <= idx < len(arr):
return _element_read(arr[idx])
return None
@_register((list, 'fill'))
def _arr_fill(arr: list, args: list[Value]) -> Value:
if not args:
return arr
value = args[0]
n = len(arr)
start = _to_index(args[1]) if len(args) > 1 else 0
end = _to_index(args[2]) if len(args) > 2 else n
if start < 0:
start = max(n + start, 0)
if end < 0:
end = max(n + end, 0)
for i in range(start, min(end, n)):
arr[i] = value
return arr
_ARRAY_HOF_METHODS = frozenset({
'every', 'some', 'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex',
})
_BUFFER_PRESERVING_HOFS = frozenset({'map', 'filter'})
def _to_js_integer(args: list[Value], round_to_integer) -> Value:
"""
Shared implementation for the integer-valued `Math` roundings (floor/ceil/round/trunc). Passes NaN
and the infinities through unchanged and preserves the sign of a negative-zero result, which JS
requires (e.g. `Math.round(-0)` is `-0`, observable as `1 / Math.round(-0) === -Infinity`).
"""
v = to_number(args[0]) if args else float('nan')
if v != v:
return float('nan')
if math.isinf(v):
return v
result = int(round_to_integer(v))
if result == 0 and math.copysign(1.0, v) < 0:
return -0.0
return result
@_register(('Math', 'floor'))
def _math_floor(args: list[Value]) -> Value:
return _to_js_integer(args, math.floor)
@_register(('Math', 'ceil'))
def _math_ceil(args: list[Value]) -> Value:
return _to_js_integer(args, math.ceil)
def _round_half_up(v: float) -> float:
"""
Round *v* to the nearest integer, ties toward positive infinity, matching JS `Math.round`. The
result is taken from the fractional distance to the floor rather than `floor(v + 0.5)`, whose
addition rounds the largest double below `0.5` up to `1.0` and would yield `1` instead of `0`.
"""
lower = math.floor(v)
return lower if v - lower < 0.5 else lower + 1
@_register(('Math', 'round'))
def _math_round(args: list[Value]) -> Value:
return _to_js_integer(args, _round_half_up)
@_register(('Math', 'abs'))
def _math_abs(args: list[Value]) -> Value:
return abs(to_number(args[0])) if args else float('nan')
@_register(('Math', 'pow'))
def _math_pow(args: list[Value]) -> Value:
if len(args) < 2:
return float('nan')
return _js_pow(to_number(args[0]), to_number(args[1]))
@_register(('Math', 'sqrt'))
def _math_sqrt(args: list[Value]) -> Value:
v = to_number(args[0]) if args else float('nan')
if v < 0:
return float('nan')
return math.sqrt(v)
@_register(('Math', 'min'))
def _math_min(args: list[Value]) -> Value:
if not args:
return float('inf')
values = [to_number(a) for a in args]
if any(v != v for v in values):
return float('nan')
result = min(values)
if result == 0 and any(math.copysign(1.0, v) < 0 for v in values):
return -0.0
return result
@_register(('Math', 'max'))
def _math_max(args: list[Value]) -> Value:
if not args:
return float('-inf')
values = [to_number(a) for a in args]
if any(v != v for v in values):
return float('nan')
result = max(values)
if result == 0 and any(math.copysign(1.0, v) > 0 for v in values):
return 0.0
return result
@_register(('Math', 'trunc'))
def _math_trunc(args: list[Value]) -> Value:
return _to_js_integer(args, math.trunc)
@_register(('Math', 'sign'))
def _math_sign(args: list[Value]) -> Value:
v = to_number(args[0]) if args else float('nan')
if v != v:
return v
if v > 0:
return 1
if v < 0:
return -1
return v
def _math_log_impl(args: list[Value], fn) -> Value:
v = to_number(args[0]) if args else float('nan')
if v <= 0:
return float('-inf') if v == 0 else float('nan')
return fn(v)
@_register(('Math', 'log'))
def _math_log(args: list[Value]) -> Value:
return _math_log_impl(args, math.log)
@_register(('Math', 'log2'))
def _math_log2(args: list[Value]) -> Value:
return _math_log_impl(args, math.log2)
@_register((None, 'parseInt'))
@_register(('Number', 'parseInt'))
def _global_parse_int(args: list[Value]) -> Value:
"""
`Number.parseInt` is registered to the same function rather than to a copy of it, because the
specification says the two property values *are* the same function object; an obfuscator
reaching the global through its `Number` spelling is reaching this.
The radix is read with ToInt32 rather than by truncation, because that is the coercion the
specification names and the two disagree outside the int32 range: `parseInt('10', 2 ** 32 + 16)`
is `16`, the radix the wrap lands on, and not the `NaN` an out-of-range value would answer.
"""
if not args:
return float('nan')
s = to_string(args[0])
radix = _to_int32(to_number(args[1])) if len(args) > 1 else 0
result = js_parse_int(s, radix)
if result is None:
return float('nan')
return result
@_register((None, 'parseFloat'))
@_register(('Number', 'parseFloat'))
def _global_parse_float(args: list[Value]) -> Value:
if not args:
return float('nan')
return js_parse_float(to_string(args[0]))
@_register((None, 'isNaN'))
def _global_is_nan(args: list[Value]) -> Value:
v = to_number(args[0]) if args else float('nan')
return v != v
@_register((None, 'isFinite'))
def _global_is_finite(args: list[Value]) -> Value:
v = to_number(args[0]) if args else float('nan')
return math.isfinite(v)
@_register((None, 'Number'))
def _global_number(args: list[Value]) -> Value:
if not args:
return 0
return to_number(args[0])
@_register((None, 'String'))
def _global_string(args: list[Value]) -> Value:
if not args:
return ''
return to_string(args[0])
@_register((None, 'atob'))
def _global_atob(args: list[Value]) -> Value:
"""
Apply the WHATWG forgiving-base64 decode, which is what `atob` answers with. Padding is read
rather than supplied: the decode takes off one or two `=` only from an argument whose length is
already a multiple of four, and every `=` that survives that is a character outside the alphabet
and refuses the whole argument. Completing the group instead is how `atob('QQ=')` answered `'A'`
where the engine throws, so a call the program never returns from folded to a value.
"""
if not args:
raise InterpreterError
s = to_string(args[0])
try:
cleaned = _RE_ASCII_WHITESPACE.sub('', s)
if len(cleaned) % 4 == 0:
cleaned = cleaned[:-2] if cleaned.endswith('==') else cleaned.removesuffix('=')
if len(cleaned) % 4 == 1 or '=' in cleaned:
raise InterpreterError
padded = cleaned + '=' * (-len(cleaned) % 4)
return base64.b64decode(padded, validate=True).decode('latin-1')
except Exception:
raise InterpreterError
@_register((None, 'btoa'))
def _global_btoa(args: list[Value]) -> Value:
if not args:
raise InterpreterError
s = to_string(args[0])
try:
return base64.b64encode(s.encode('latin-1')).decode('ascii')
except Exception:
raise InterpreterError
_UNESCAPE_PATTERN = re.compile(r'%u([0-9A-Fa-f]{4})|%([0-9A-Fa-f]{2})')
_RE_ASCII_WHITESPACE = re.compile(F"[{re.escape(ASCII_WHITESPACE)}]")
"""
The characters forgiving-base64 decoding removes before it reads an `atob` argument. The set is
`refinery.lib.scripts.js.token.ASCII_WHITESPACE` and is narrower than Python's `\\s`, which also
takes the vertical tab and `U+001C` through `U+001F`, and narrower than
`refinery.lib.scripts.js.numbers.TRIMMABLE_WHITESPACE`, which takes the space separators and the
byte order mark. Every character outside it is a character `atob` throws on, so reading it as
padding deletes a throw.
"""
_RE_NON_BASE64 = re.compile(r'[^A-Za-z0-9+/=]')
_RE_URI_ESCAPE = re.compile('%([0-9A-Fa-f]{2})')
"""
One escape of a URI: a percent sign and the two hexadecimal digits spelling one octet. A decode
throws a `URIError` for a percent sign anything else follows, so a sign this does not match is not
a character it passes through: `decodeURIComponent('100%')` throws where `urllib.parse.unquote`
answers `'100%'`.
"""
@_register((None, 'unescape'))
def _global_unescape(args: list[Value]) -> Value:
if not args:
return 'undefined'
s = to_string(args[0])
return _UNESCAPE_PATTERN.sub(lambda m: chr(int(m.group(1) or m.group(2), 16)), s)
def _uri_escape_octet(s: str, index: int) -> int:
match = _RE_URI_ESCAPE.match(s, index)
if match is None:
raise InterpreterError
return int(match.group(1), 16)
def _utf8_sequence_length(lead: int) -> int:
if 0xC0 <= lead < 0xE0:
return 2
if 0xE0 <= lead < 0xF0:
return 3
if 0xF0 <= lead < 0xF8:
return 4
raise InterpreterError
@_register((None, 'decodeURIComponent'))
def _global_decode_uri_component(args: list[Value]) -> Value:
"""
Apply the `Decode` operation of the specification over the code units of the argument, with no
escape preserved. A character no escape introduced is copied straight into the answer, a
surrogate the argument itself spells included; an escaped octet outside ASCII must open a run
of escaped octets that is strict UTF-8 — an overlong form, an encoded surrogate, or a code
point past the last one all throw — and the code point that run encodes comes back as the code
units that spell it, so a decoded astral character is the pair a literal holding it would be.
"""
if not args:
raise InterpreterError
s = to_string(args[0])
result: list[str] = []
index = 0
while index < len(s):
unit = s[index]
if unit != '%':
result.append(unit)
index += 1
continue
lead = _uri_escape_octet(s, index)
index += 3
if lead < 0x80:
result.append(chr(lead))
continue
octets = bytearray([lead])
for _ in range(_utf8_sequence_length(lead) - 1):
octets.append(_uri_escape_octet(s, index))
index += 3
try:
decoded = octets.decode('utf-8')
except UnicodeDecodeError:
raise InterpreterError
result.append(to_code_units(decoded))
return ''.join(result)
@_register((None, 'encodeURIComponent'))
def _global_encode_uri_component(args: list[Value]) -> Value:
if not args:
raise InterpreterError
s = spell_astral_characters(to_string(args[0]))
try:
return urllib.parse.quote(s, safe="!'()*~-._")
except Exception:
raise InterpreterError
@_register(('Object', 'keys'))
def _object_keys(args: list[Value]) -> Value:
if not args:
raise InterpreterError
obj = args[0]
if isinstance(obj, dict):
return own_property_keys(obj)
if isinstance(obj, list):
# An array's own keys are its indices in ascending order, minus the holes: a hole is a
# position the array holds no property at, which is what `Object.keys` reports as absent.
return [str(i) for i, item in enumerate(obj) if item is not JS_HOLE]
raise InterpreterError
@_register(('Object', 'values'))
def _object_values(args: list[Value]) -> Value:
if args and isinstance(args[0], dict):
return [args[0][key] for key in own_property_keys(args[0])]
raise InterpreterError
@_register(('Object', 'entries'))
def _object_entries(args: list[Value]) -> Value:
if args and isinstance(args[0], dict):
return [[key, args[0][key]] for key in own_property_keys(args[0])]
raise InterpreterError
@_register(('Array', 'from'))
def _array_from(args: list[Value]) -> Value:
if not args:
return []
src = args[0]
if isinstance(src, str):
return code_points(src)
if isinstance(src, list):
# The iteration `Array.from` runs over an array yields a hole as `undefined` rather
# than skipping it, so the copy it builds holds an element there.
return [_element_read(item) for item in src]
raise InterpreterError
@_register(('Array', 'isArray'))
def _array_is_array(args: list[Value]) -> Value:
return isinstance(args[0], list) and not isinstance(args[0], JsBuffer) if args else False
@_register(('Buffer', 'from'))
def _buffer_from(args: list[Value]) -> Value:
if not args:
raise InterpreterError
data = args[0]
if isinstance(data, list):
return JsBuffer(_to_int(v) & 0xFF for v in data)
if not isinstance(data, str):
raise InterpreterError
encoding = args[1] if len(args) > 1 else 'utf8'
if not isinstance(encoding, str):
raise InterpreterError
try:
if encoding == 'base64':
normalized = data.replace('-', '+').replace('_', '/')
stripped = _RE_NON_BASE64.sub('', normalized)
padded = stripped.rstrip('=')
padded = padded + '=' * (-len(padded) % 4)
return JsBuffer(base64.b64decode(padded))
if encoding in ('utf8', 'utf-8'):
return JsBuffer(spell_astral_characters(data).encode('utf-8'))
if encoding in ('latin1', 'binary'):
return JsBuffer(data.encode('latin-1'))
if encoding == 'hex':
return JsBuffer(bytes.fromhex(data))
except Exception:
raise InterpreterError
raise InterpreterError
@_register((JsBuffer, 'toString'))
def _list_to_string(buf: list, args: list[Value]) -> Value:
encoding = args[0] if args else 'utf8'
if not isinstance(encoding, str):
raise InterpreterError
try:
raw = bytes(_to_int(v) & 0xFF for v in buf)
except (TypeError, ValueError, OverflowError):
raise InterpreterError
try:
if encoding in ('utf8', 'utf-8'):
return raw.decode('utf-8')
if encoding in ('latin1', 'binary'):
return raw.decode('latin-1')
if encoding == 'base64':
return base64.b64encode(raw).decode('ascii')
if encoding == 'hex':
return raw.hex()
if encoding == 'ascii':
return raw.decode('ascii')
except Exception:
raise InterpreterError
raise InterpreterError
STATIC_OBJECTS = frozenset({'Math', 'String', 'Object', 'Array', 'Number', 'JSON', 'Buffer'})
_COERCING_GLOBALS = frozenset({'String', 'Number'})
"""
The global functions whose value *is* the coercion of their argument: `String(x)` is ToString and
`Number(x)` is ToNumber as callable spellings. They are the one pair the interpreter guards at its
dispatch, because their argument is exactly the value a program-installed conversion replaces;
the registry arms that coerce an argument in the course of a larger operation keep the behavior
they have, which the fold-admission gate outside the interpreter is what stands behind.
"""
def is_runtime_name(name: str) -> bool:
"""
Return True if `name` is a known JavaScript runtime symbol — either a static object namespace
(e.g. `Math`, `String`) or a global function registered in the builtin registry (e.g.
`parseInt`, `parseFloat`).
"""
return name in STATIC_OBJECTS or (None, name) in BUILTIN_REGISTRY
def names_runtime_builtin(node: JsIdentifier, model: SemanticModel) -> bool:
"""
Whether *node* names a runtime symbol this interpreter models, read where nothing has bound the
name. The spelling alone does not settle it: `catch (parseInt)` and `function o(parseInt)` both
give the name a value of their own, and evaluating a call to it as the built-in then computes an
answer the program never produces.
"""
return is_runtime_name(node.name) and name_is_unbound(node, model)
def _declares_a_function_inside_a_block(
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
) -> bool:
"""
Whether *func*'s body declares a function anywhere other than directly among its own statements
- inside a block, a `switch` case, or the clause of an `if` or a loop.
Which list a declaration stands in is `statement_list_holding`'s answer, so a labelled one is
read as standing where its label stands, exactly as the scope model reads it. The walk is the
boundary-respecting one, so a function written inside a *nested* function is that function's
business and not this one's.
"""
body = func.body
if not isinstance(body, JsBlockStatement):
return False
return any(
isinstance(node, JsFunctionDeclaration) and statement_list_holding(node) is not body
for node in walk_scope(body)
)
class JsInterpreter:
"""
Execute a JavaScript function body with concrete argument values. Returns a Python value or
raises `IrreducibleExpression` when the return value cannot be reduced to a simple value.
"""
def __init__(
self, *,
max_iterations: int = MAX_ITERATIONS,
max_string_len: int = MAX_STRING_LEN,
max_array_len: int = MAX_ARRAY_LEN,
max_recursion: int = _MAX_RECURSION,
effects: EffectModel | None = None,
model: SemanticModel | None = None,
anchor: Node | None = None,
tampering: TamperingModel | None = None,
closure: Mapping[str, Value] | None = None,
closure_env: Mapping[int, Mapping[str, Value]] | None = None,
established: Callable[[JsFunctionNode], bool] | None = None,
depth: int = 0,
):
self.max_iterations = max_iterations
self.max_string_len = max_string_len
self.max_array_len = max_array_len
self.max_recursion = max_recursion
self._effects = effects
self._model = model if model is not None else effects and effects.model
self._anchor = anchor
self._tampering = tampering
"""
The scope authority, held separately from *effects*. Whether a name still reaches the host is a
question about bindings alone, so a caller that has only the semantic model — reflection builds
one and would pay for an effect model it never consults — can answer it by passing *model*.
"""
self._closure: Mapping[str, Value] = closure or {}
self._closure_env: Mapping[int, Mapping[str, Value]] = closure_env or {}
self._established = established
self._env: dict[str, Value] = {}
self._iterations = 0
self._depth = depth
def execute(
self,
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
arguments: list[Value],
) -> Value:
"""
The value a call to *func* with *arguments* answers, which is the value its body returned
only where a call answers that value at all. An `async` function answers a promise and a
generator answers a generator object whose body has not run, so `wraps_return` ends the
interpretation rather than reporting a value the call never had. `InterpreterError` is what
ends it and not `IrreducibleExpression`: the latter hands the caller the body's return
expression to splice into the call site, which is the same wrong answer written a second
way.
A body declaring a function anywhere but among its own statements ends it too. This
interpreter has one environment per call and none per block, so it holds such a function
from the entry of the body, where the language holds it in the block it is written in and,
in sloppy code, in the enclosing scope only from the point the declaration runs. A read
before that point is the difference, and it is one this answers with the function where a
program answers `undefined`.
"""
if wraps_return(func):
raise InterpreterError
if _declares_a_function_inside_a_block(func):
raise InterpreterError
params = func.params
param_names: list[str] = []
for p in params:
if not isinstance(p, JsIdentifier):
raise InterpreterError
param_names.append(p.name)
self._env = {}
for i, name in enumerate(param_names):
self._env[name] = arguments[i] if i < len(arguments) else None
body = func.body
for name in self._collect_hoisted_var_names(body):
if name == 'arguments' and not isinstance(func, JsArrowFunctionExpression):
# The call has already bound `arguments` to the argument object before any
# statement runs, so an entry holding `undefined` would answer a read with a
# value the call never made — including under a `var` that displaces it, where
# leaving the name unbound refuses instead.
continue
self._env.setdefault(name, None)
arguments_object = self._arguments_object(func, arguments)
if arguments_object is not None:
self._env['arguments'] = arguments_object
for name, value in self._closure.items():
if name not in self._env:
self._env[name] = _deep_copy_value(value)
self._iterations = 0
if isinstance(body, JsBlockStatement):
try:
self._exec_statements(body.body)
except _ReturnSignal as r:
return r.value
except _ReturnIrreducible as r:
raise IrreducibleExpression(r.node)
except IrreducibleExpression:
raise InterpreterError
except _ThrowSignal:
if self._depth == 0:
raise InterpreterError
raise
return None
if body is not None:
try:
return self._eval(body)
except IrreducibleExpression:
raise IrreducibleExpression(body)
except _ThrowSignal:
if self._depth == 0:
raise InterpreterError
raise
return None
@staticmethod
def _collect_hoisted_var_names(body) -> list[str]:
"""
Collect the names of all `var` declarations in *body*, which JavaScript hoists to the top of
the function scope (initialized to `undefined`). Nested function bodies are not traversed.
Reading a hoisted name before its initializer must yield `undefined`, not an unresolved free
identifier.
"""
if not isinstance(body, JsBlockStatement):
return []
names: list[str] = []
for node in walk_scope(body, include_root_body=True):
if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR:
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
names.append(decl.id.name)
return names
def _arguments_object(self, func, values: list[Value]) -> JsArguments | None:
"""
The `arguments` object a call to *func* may be given, or `None` where the body may not be
handed one. Whether the object a call models from its argument values can stand in for
the name is `call_supplies_an_arguments_object`'s question, shared with the fold that
asks which names a call supplies. Every failure leaves the name unbound, so the read
declines through the `IrreducibleExpression` every unresolved name takes, and a nested
function's own `arguments` declines the same way in its own environment.
"""
model = self._model
if model is None or not call_supplies_an_arguments_object(model, func):
return None
return JsArguments(values)
def _exec_statements(self, stmts: list) -> None:
for stmt in stmts:
self._exec_statement(stmt)
def _exec_statement(self, stmt) -> None:
if isinstance(stmt, JsVariableDeclaration):
self._exec_var_decl(stmt)
elif isinstance(stmt, JsExpressionStatement):
self._eval(stmt.expression)
elif isinstance(stmt, JsIfStatement):
self._exec_if(stmt)
elif isinstance(stmt, JsSwitchStatement):
self._exec_switch(stmt)
elif isinstance(stmt, JsForStatement):
self._exec_for(stmt)
elif isinstance(stmt, JsWhileStatement):
self._exec_while(stmt)
elif isinstance(stmt, JsDoWhileStatement):
self._exec_do_while(stmt)
elif isinstance(stmt, JsForInStatement):
self._exec_for_in(stmt)
elif isinstance(stmt, JsForOfStatement):
self._exec_for_of(stmt)
elif isinstance(stmt, JsReturnStatement):
if stmt.argument is None:
raise _ReturnSignal(None)
try:
value = self._eval(stmt.argument)
except IrreducibleExpression:
raise _ReturnIrreducible(stmt.argument)
raise _ReturnSignal(value)
elif isinstance(stmt, JsBreakStatement):
raise _BreakSignal
elif isinstance(stmt, JsContinueStatement):
raise _ContinueSignal
elif isinstance(stmt, JsBlockStatement):
self._exec_statements(stmt.body)
elif isinstance(stmt, JsTryStatement):
self._exec_try(stmt)
elif isinstance(stmt, JsThrowStatement):
value = self._eval(stmt.argument) if stmt.argument else None
raise _ThrowSignal(value)
elif isinstance(stmt, JsFunctionDeclaration):
if isinstance(stmt.id, JsIdentifier):
self._env[stmt.id.name] = stmt
elif isinstance(stmt, JsEmptyStatement):
return
else:
raise InterpreterError
def _exec_var_decl(self, node: JsVariableDeclaration) -> None:
for decl in node.declarations:
if not isinstance(decl, JsVariableDeclarator):
raise InterpreterError
if not isinstance(decl.id, JsIdentifier):
raise InterpreterError
name = decl.id.name
if decl.init is not None:
self._env[name] = self._eval(decl.init)
elif node.kind == JsVarKind.VAR:
self._env.setdefault(name, None)
else:
self._env[name] = None
def _exec_if(self, node: JsIfStatement) -> None:
if to_boolean(self._eval(node.test)):
if node.consequent:
self._exec_statement(node.consequent)
elif node.alternate:
self._exec_statement(node.alternate)
def _exec_switch(self, node: JsSwitchStatement) -> None:
"""
A switch runs the clause whose test the discriminant matches, and where none matches, the
default clause. The default is chosen only after every test has been evaluated, so a clause
written after it is still asked. Writing the default in the middle of a dispatcher is a way
to make the clause order look like the run order when it is not, and reading the default the
moment it is reached answers with the wrong branch for every discriminant a later clause
would have claimed.
Once a clause is chosen, execution enters it and falls through the clauses behind it, which
is why the run is a walk from the chosen index rather than of the clause alone. Tests behind
the match are not evaluated: a match ends the search, and an expression that would throw is
never reached.
A second default clause is an early error, so a switch carrying one is not a program and is
refused rather than interpreted as though the first of them governed.
"""
cases = [case for case in node.cases if isinstance(case, JsSwitchCase)]
if len(cases) != len(node.cases):
raise InterpreterError
defaults = [index for index, case in enumerate(cases) if case.test is None]
if len(defaults) > 1:
raise InterpreterError
discriminant = self._eval(node.discriminant)
chosen = None
for index, case in enumerate(cases):
if case.test is None:
continue
if self._strict_equal(discriminant, self._eval(case.test)):
chosen = index
break
if chosen is None:
if not defaults:
return
chosen = defaults[0]
try:
for case in cases[chosen:]:
self._exec_statements(case.body)
except _BreakSignal:
return
def _exec_loop_body(self, body) -> bool:
if not body:
return False
try:
self._exec_statement(body)
except _BreakSignal:
return True
except _ContinueSignal:
pass
return False
def _exec_for(self, node: JsForStatement) -> None:
if node.init:
if isinstance(node.init, JsVariableDeclaration):
self._exec_var_decl(node.init)
else:
self._eval(node.init)
while True:
self._tick()
if node.test and not to_boolean(self._eval(node.test)):
break
if self._exec_loop_body(node.body):
break
if node.update:
self._eval(node.update)
def _exec_while(self, node: JsWhileStatement) -> None:
while True:
self._tick()
if not to_boolean(self._eval(node.test)):
break
if self._exec_loop_body(node.body):
break
def _exec_do_while(self, node: JsDoWhileStatement) -> None:
while True:
self._tick()
if self._exec_loop_body(node.body):
break
if not to_boolean(self._eval(node.test)):
break
def _exec_for_in(self, node: JsForInStatement) -> None:
"""
Walk the enumerable names of the receiver, which is every name it owns and every enumerable
one its prototype chain holds.
The chain contributes none of its own while the program leaves it alone: measured across
sixteen receiver kinds, every name the language installs on a prototype — and every method a
class body writes onto one — is non-enumerable. So the own names are the whole answer
exactly while the chain is intact, and where it is not, the walk is refused rather than
answered a name short. Refusing one walk is what that costs, which is why this asks
`read_chain_intact` rather than the arm that overlooks a reflective surface.
The question asked is about the whole chain rather than about one key, which over-refuses
where an own name shadows an added inherited one: `Object.prototype.z = 9` over a receiver
holding its own `z` walks `z` once either way. That costs a fold and never an answer.
"""
right = self._eval(node.right)
if right is None or right is JS_NULL:
return
if not isinstance(right, (dict, list)):
raise InterpreterError
if not self._chain_is_intact(type(right)):
raise InterpreterError
keys: list
if isinstance(right, dict):
keys = own_property_keys(right)
else:
keys = [str(i) for i, item in enumerate(right) if item is not JS_HOLE]
var_name = self._get_loop_var(node.left)
for key in keys:
self._tick()
self._env[var_name] = key
if self._exec_loop_body(node.body):
break
def _exec_for_of(self, node: JsForOfStatement) -> None:
"""
Walk the values the receiver's iterator yields, which is the iterator a list's or a
string's prototype supplies — so the walk asks the same chain question a for-in walk does
before it yields anything: a program that patched `Array.prototype[Symbol.iterator]` makes
the real loop answer something other than the elements, and an interpretation that walks
them regardless is answering for it.
"""
right = self._eval(node.right)
if right is None or right is JS_NULL:
_js_throw('TypeError', F'{to_string(right)} is not iterable')
if not isinstance(right, (list, str)):
raise InterpreterError
if not self._chain_is_intact(type(right)):
raise InterpreterError
if isinstance(right, list):
# An array's iterator yields a hole as `undefined` — it asks whether the array has a
# property at the index and takes the value on either branch — so a hole is not a skip.
items = [None if item is JS_HOLE else item for item in right]
else:
items = code_points(right)
var_name = self._get_loop_var(node.left)
for item in items:
self._tick()
self._env[var_name] = item
if self._exec_loop_body(node.body):
break
def _exec_try(self, node: JsTryStatement) -> None:
thrown: _ThrowSignal | None = None
propagate: Exception | None = None
try:
if node.block:
self._exec_statements(node.block.body)
except _ThrowSignal as exc:
thrown = exc
except (
IrreducibleExpression,
InterpreterError,
_ReturnSignal,
_BreakSignal,
_ContinueSignal,
_ReturnIrreducible,
) as exc:
propagate = exc
if propagate is not None:
if node.finalizer:
self._exec_statements(node.finalizer.body)
raise propagate
if thrown is not None:
if node.handler and node.handler.body:
param_name: str | None = None
had_param: bool = False
prev_param: Value = None
if isinstance(node.handler.param, JsIdentifier):
param_name = node.handler.param.name
had_param = param_name in self._env
prev_param = self._env.get(param_name)
self._env[param_name] = thrown.value
handler_outcome: Exception | None = None
try:
self._exec_statements(node.handler.body.body)
except (
_ThrowSignal,
IrreducibleExpression,
InterpreterError,
_ReturnSignal,
_BreakSignal,
_ContinueSignal,
_ReturnIrreducible,
) as exc:
handler_outcome = exc
finally:
if param_name is not None:
if had_param:
self._env[param_name] = prev_param
else:
self._env.pop(param_name, None)
if node.finalizer:
self._exec_statements(node.finalizer.body)
if handler_outcome is not None:
raise handler_outcome
return
if node.finalizer:
self._exec_statements(node.finalizer.body)
raise thrown
if node.finalizer:
self._exec_statements(node.finalizer.body)
def _get_loop_var(self, left) -> str:
if isinstance(left, JsVariableDeclaration):
if len(left.declarations) == 1:
decl = left.declarations[0]
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
return decl.id.name
if isinstance(left, JsIdentifier):
return left.name
raise InterpreterError
def _tick(self) -> None:
self._iterations += 1
if self._iterations > self.max_iterations:
raise InterpreterError
def _eval(self, expr) -> Value:
if expr is None:
return None
if isinstance(expr, JsStringLiteral):
if not expr.terminated or expr.value is None:
raise IrreducibleExpression(expr)
return expr.value
if isinstance(expr, JsNumericLiteral):
return expr.value
if isinstance(expr, JsBooleanLiteral):
return expr.value
if isinstance(expr, JsNullLiteral):
return JS_NULL
if isinstance(expr, JsIdentifier):
return self._eval_identifier(expr)
if isinstance(expr, JsBinaryExpression):
return self._eval_binary(expr)
if isinstance(expr, JsUnaryExpression):
return self._eval_unary(expr)
if isinstance(expr, JsUpdateExpression):
return self._eval_update(expr)
if isinstance(expr, JsLogicalExpression):
return self._eval_logical(expr)
if isinstance(expr, JsAssignmentExpression):
return self._eval_assignment(expr)
if isinstance(expr, JsCallExpression):
return self._eval_call(expr)
if isinstance(expr, JsMemberExpression):
return self._eval_member(expr)
if isinstance(expr, JsConditionalExpression):
test = self._eval(expr.test)
return self._eval(expr.consequent) if to_boolean(test) else self._eval(expr.alternate)
if isinstance(expr, JsArrayExpression):
return self._eval_array(expr)
if isinstance(expr, JsSequenceExpression):
result: Value = None
for e in expr.expressions:
result = self._eval(e)
return result
if isinstance(expr, JsTemplateLiteral):
return self._eval_template(expr)
if isinstance(expr, JsObjectExpression):
return self._eval_object(expr)
if isinstance(expr, (JsFunctionExpression, JsArrowFunctionExpression)):
return expr
if isinstance(expr, JsParenthesizedExpression):
return self._eval(expr.expression)
raise InterpreterError
def _resolve_function_node(self, node: JsIdentifier) -> JsFunctionNode | None:
"""
The single function *node* names, or `None`. Delegates to `EffectModel.unambiguous_function`: a
function declaration or a bare-assignment (`var f; f = function(){}`) resolves, but a name
reassigned away from a value it already held stays unresolved. When an *established* predicate was
supplied, a resolved function is returned only if it is in place before the call site folding began
— a bare-assignment or initializer function reached before its establishing node has run reads a
temporal dead zone or a hoisted `undefined` at runtime, so resolving it here would replace that
throw with a value. A hoisted function declaration is always established and passes unconditionally.
"""
effects = self._effects
if effects is None:
return None
func = effects.unambiguous_function(effects.model.resolve(node))
if func is None:
return None
if self._established is not None and not self._established(func):
return None
return func
def _names_a_runtime_builtin(self, node: JsIdentifier) -> bool:
"""
Whether *node* still reaches the host built-in of that name. Without a model this answers
`False` and the call becomes irreducible, for the same reason `_names_a_global_value` does.
"""
model = self._model
return model is not None and names_runtime_builtin(node, model)
def _names_a_static_object(self, node: JsIdentifier) -> bool:
"""
Whether *node*, standing in receiver position ahead of a method the registry knows, still
reaches the host object of that name. It is the question `_names_a_runtime_builtin` asks
for a bare callee, and the static branch has to ask it too: `var Number = {parseInt: ...}`
gives the name a value of its own, and answering the call from the registry then computes a
number the program never produces. Without a model this answers `True` rather than `False`,
matching `_callee_is_intact`: an interpreter used on one expression in isolation cannot see
a scope, so the assumption is the caller's.
"""
if node.name in self._env:
return False
model = self._model
return model is None or names_runtime_builtin(node, model)
def _names_a_global_value(self, node: JsIdentifier) -> bool:
"""
Whether *node* is `undefined`, `NaN` or `Infinity` still denoting the global value, which only
the semantic model can say. Without one this answers `False` and the name becomes irreducible:
an interpreter that cannot see the scope cannot tell the global from a binding an enclosing
function supplies, and guessing the global is how a call to `function f(NaN) { return NaN + 1 }`
folded to `NaN` instead of to its argument.
"""
model = self._model
return model is not None and names_global_value(node, model)
def _resolves_to_a_binding(self, node: JsIdentifier) -> bool:
"""
Whether *node* resolves to any binding at all. A name the model binds but `_env` does not hold
has a value this interpreter does not know — it may belong to an enclosing scope that is not in
the closure, or to a `let` whose declarator has not run, which is a read in its temporal dead
zone that throws — so the well-known-global `typeof` fallback must not answer for it.
"""
model = self._model
return model is not None and model.resolve(node) is not None
def _eval_identifier(self, node: JsIdentifier) -> Value:
name = node.name
if name in self._env:
return self._env[name]
func = self._resolve_function_node(node)
if func is not None:
return func
if self._names_a_global_value(node):
return GLOBAL_VALUE_NAMES[name]
raise IrreducibleExpression(node)
def _js_add(self, left: Value, right: Value) -> Value:
"""
Replicate the JavaScript `+` operator: apply ToPrimitive to both operands, then concatenate
as strings if either is a string, otherwise add numerically.
"""
left = _to_primitive(left)
right = _to_primitive(right)
if isinstance(left, str) or isinstance(right, str):
result = to_string(left) + to_string(right)
if len(result) > self.max_string_len:
raise InterpreterError
return result
return to_number(left) + to_number(right)
def _eval_binary(self, node: JsBinaryExpression) -> Value:
return self._apply_binary(node.operator, self._eval(node.left), self._eval(node.right))
def _apply_binary(self, op: str, left: Value, right: Value) -> Value:
"""
Apply the binary operator *op* to two already-evaluated values — the one place this interpreter
decides what an operator means. A binary expression and the arithmetic step of a compound
assignment ask the same question, so they must ask it here.
`eval_binary_op` handles the numeric operators alone, which is why the string cases resolve first:
`+` needs ToPrimitive on both operands and may yield a concatenation, and a relational operator
compares two strings lexicographically rather than numerically. Everything after that is a number.
Every operator but strict equality coerces its operands — `+` and the relational operators to
primitives, the numeric ones to numbers, `==` to whichever the other side demands, and `in` its
left side to a key — so every one of them asks the coercion guard first, the `in` operator of
its key alone: an object whose conversion a program installed decides what all of these answer,
and strict equality is the one operator that runs nothing.
"""
if op not in ('===', '!=='):
self._require_uninterceptable(left)
if op != 'in':
self._require_uninterceptable(right)
if op == '===':
return self._strict_equal(left, right)
if op == '!==':
return not self._strict_equal(left, right)
if op == '==':
return self._loose_equal(left, right)
if op == '!=':
return not self._loose_equal(left, right)
if op == '+':
return self._js_add(left, right)
if op == 'in':
return self._eval_in(left, right)
if op == 'instanceof':
raise InterpreterError
if op in RELATIONAL_OPS:
primitive_left = _to_primitive(left)
primitive_right = _to_primitive(right)
if isinstance(primitive_left, str) and isinstance(primitive_right, str):
return RELATIONAL_OPS[op](primitive_left, primitive_right)
result = eval_binary_op(op, to_number(left), to_number(right))
if result is None:
raise InterpreterError
return result
def _eval_in(self, left: Value, right: Value) -> bool:
"""
Whether *right* has a property named by *left*, which the operator answers for the whole
prototype chain and not for the own properties alone. The three ways that can go are the
three this file has words for: the value owns a slot there, the chain supplies the name, or
neither does and it is `property_provably_absent`'s to say whether that settles anything.
A name it will not decide without the chain is one this operator cannot answer `false` for
either, so the interpretation stops rather than reporting what the own properties happen to
show. `Object.prototype.z = 9` makes `'z' in {}` true, and reading a refusal as an absence
is what a program written to be read wrongly is looking for.
"""
if not isinstance(right, (dict, list)):
raise InterpreterError
key = to_string(left)
if read_data_property(right, key)[0] is MemberRead.FOUND:
return True
chain_intact = property_is_inherited_from_an_intact_chain(self._effects, type(right), key)
if not chain_intact and self._builtins_intact():
chain_intact = property_is_inherited_from_an_unwritten_chain(
self._effects, type(right), key)
if chain_intact:
return True
if self._property_is_absent(right, key):
return False
raise InterpreterError
def _eval_array(self, expr: JsArrayExpression) -> list[Value]:
"""
The list an array literal builds, refusing one that would hold a hole. An elision is not an
element whose value is `undefined`: `[1, , 3][1]` reads `Array.prototype[1]` where
`[1, undefined, 3][1]` reads the element, so the two answer differently wherever that
prototype is written, and `indexOf` and the callback methods skip the first while visiting
the second.
A hole is representable in this domain — a store growing an array past its end builds one —
but a literal holding one has no fold to go to: `value_to_node` refuses a holey list, so
building it here would answer no call. The syntax model spells an elision as no element at
all, which is the shape this refusal leaves standing.
"""
if any(element is None for element in expr.elements):
raise InterpreterError
return [self._eval(element) for element in expr.elements]
def _eval_unary(self, node: JsUnaryExpression) -> Value:
"""
Apply a unary operator through `UNARY_OPS`, which holds the ones that are functions of the
operand's value. `typeof` on a bare name is answered before the operand is evaluated because it
is the one expression that reads a name without requiring it to exist: `typeof missing` is
`'undefined'` where evaluating `missing` is a `ReferenceError`.
"""
op = node.operator
operand = node.operand
if op == 'typeof' and isinstance(operand, JsIdentifier):
name = operand.name
if name in self._env:
return js_typeof(self._env[name])
if self._resolve_function_node(operand) is not None:
return 'function'
if self._resolves_to_a_binding(operand):
raise IrreducibleExpression(node)
environment = self._model.environment if self._model is not None else HostEnvironment.universal
result = typeof_of_global(name, environment)
if result is None:
raise IrreducibleExpression(node)
return result
apply = UNARY_OPS.get(op)
if apply is None:
raise InterpreterError
value = self._eval(operand)
if op in ('+', '-', '~'):
self._require_uninterceptable(value)
return apply(value)
def _eval_update(self, node: JsUpdateExpression) -> Value:
"""
Apply `++` or `--`. The target may be a plain identifier or a member, and both must be supported here:
`a[i]++` is the same operation as `a[i] += 1` on a value the language has already coerced to a number,
so refusing one while folding the other would make the two disagree.
"""
delta = {'++': 1, '--': -1}.get(node.operator)
if delta is None:
raise InterpreterError
target = node.argument
if isinstance(target, JsIdentifier):
name = target.name
if name not in self._env:
raise InterpreterError
self._require_uninterceptable(self._env[name])
current = to_number(self._env[name])
self._env[name] = current + delta
elif isinstance(target, JsMemberExpression):
obj = self._eval(target.object)
key = self._member_key(target)
current = self._get_property(obj, key)
self._require_uninterceptable(current)
current = to_number(current)
self._set_property(obj, key, current + delta)
else:
raise InterpreterError
return current + delta if node.prefix else current
def _short_circuits(self, operator: str, current: Value) -> bool:
"""
Whether a logical assignment (`&&=`, `||=`, `??=`) is already decided by *current*, so neither its
right operand nor the store runs. The three truthiness rules are the ones `_eval_logical` applies to
the expression forms, read in the other direction.
"""
if operator == '&&=':
return not to_boolean(current)
if operator == '||=':
return to_boolean(current)
if operator == '??=':
return current is not None and current is not JS_NULL
raise InterpreterError
def _compound_value(self, operator: str, current: Value, node: JsAssignmentExpression) -> Value:
"""
The value a compound assignment stores: its right operand evaluated and combined with *current* under
the operator the assignment names. *current* is read by the caller before this runs, because
JavaScript reads the target before evaluating the right operand — `v += (v = 10)` on `v = 5` is 15.
"""
return self._apply_binary(operator[:-1], current, self._eval(node.right))
def _eval_logical(self, node: JsLogicalExpression) -> Value:
left = self._eval(node.left)
if node.operator == '&&':
return self._eval(node.right) if to_boolean(left) else left
if node.operator == '||':
return left if to_boolean(left) else self._eval(node.right)
if node.operator == '??':
if left is None or left is JS_NULL:
return self._eval(node.right)
return left
raise InterpreterError
def _eval_assignment(self, node: JsAssignmentExpression) -> Value:
if isinstance(node.left, JsMemberExpression):
return self._eval_member_assignment(node)
if not isinstance(node.left, JsIdentifier):
raise InterpreterError
name = node.left.name
op = node.operator
if op == '=':
value = self._eval(node.right)
self._env[name] = value
return value
current = self._eval(node.left)
if op in LOGICAL_ASSIGNMENT_OPS:
if self._short_circuits(op, current):
return current
self._env[name] = self._eval(node.right)
return self._env[name]
self._env[name] = self._compound_value(op, current, node)
return self._env[name]
def _eval_member_assignment(self, node: JsAssignmentExpression) -> Value:
"""
Assign to a member target. The object and key expressions are evaluated exactly once, before the
operator is considered, so `a[i++] += 1` advances `i` once — and a logical assignment that
short-circuits performs no store at all.
Skipping that store is what JavaScript specifies rather than an optimization: the language makes it
observable through a setter or a frozen object, neither of which this interpreter's value domain has.
Within this domain a store of the value just read is idempotent, so no program folded here can tell
the difference — the rule is kept because the domain is a subset of the language's, not because a test
can witness it.
"""
member = node.left
if not isinstance(member, JsMemberExpression):
raise InterpreterError
obj = self._eval(member.object)
key = self._member_key(member)
if node.operator == '=':
value = self._eval(node.right)
elif node.operator in LOGICAL_ASSIGNMENT_OPS:
old = self._get_property(obj, key)
if self._short_circuits(node.operator, old):
return old
value = self._eval(node.right)
else:
value = self._compound_value(node.operator, self._get_property(obj, key), node)
self._set_property(obj, key, value)
return value
def _eval_call(self, node: JsCallExpression) -> Value:
if isinstance(node.callee, JsMemberExpression):
return self._eval_method_call(node)
if isinstance(node.callee, JsIdentifier):
return self._eval_function_call(node)
if isinstance(node.callee, (JsFunctionExpression, JsArrowFunctionExpression)):
return self._eval_inline_call(node.callee, node.arguments)
raise InterpreterError
def _eval_function_call(self, node: JsCallExpression) -> Value:
"""
Call the function a bare name denotes. The registry is consulted last, and only for a name the
scope has left alone: it records what the *host* provides, which is what a name reaches only
when nothing nearer has claimed it. Asking it first is how a call to a parameter or a `catch`
binding named `parseInt` was answered by the real `parseInt`.
"""
callee = node.callee
if not isinstance(callee, JsIdentifier):
raise InterpreterError
name = callee.name
args = [self._eval(a) for a in node.arguments]
if name in self._env:
target = self._env[name]
if isinstance(target, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)):
return self._call_function(target, args)
if node.optional and (target is None or target is JS_NULL):
return None
_js_throw('TypeError', F'{name} is not a function')
func = self._resolve_function_node(callee)
if func is not None:
return self._call_function(func, args)
builtin = BUILTIN_REGISTRY.get((None, name))
if builtin is not None and self._names_a_runtime_builtin(callee):
if name in _COERCING_GLOBALS and not all(
self._coerces_uninterceptably(arg) for arg in args
):
raise InterpreterError
return builtin(args)
raise InterpreterError
def _eval_method_call(self, node: JsCallExpression) -> Value:
member = node.callee
if not isinstance(member, JsMemberExpression):
raise InterpreterError
if (
isinstance(member.object, JsIdentifier)
and member.object.name in STATIC_OBJECTS
):
static_name = member.object.name
method_name = self._member_key(member)
args = [self._eval(a) for a in node.arguments]
builtin = BUILTIN_REGISTRY.get((static_name, method_name))
if builtin is not None:
if not self._names_a_static_object(member.object):
raise InterpreterError
if not self._callee_is_intact(node):
raise InterpreterError
return builtin(args)
raise InterpreterError
obj = self._eval(member.object)
if obj is None or obj is JS_NULL:
if member.optional:
return None
_js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading a method)")
method_name = self._member_key(member)
args = [self._eval(a) for a in node.arguments]
if isinstance(obj, (str, list)) and method_name in SEQUENCE_DATA_PROPERTIES:
# A data property is not callable. The arguments are evaluated first, as JS does, so a side
# effect or a throw inside one is not lost to this TypeError.
_js_throw('TypeError', F'{to_string(obj)}.{method_name} is not a function')
obj_type = type(obj)
builtin = BUILTIN_REGISTRY.get((obj_type, method_name))
if builtin is None and obj_type is not list and isinstance(obj, list):
builtin = BUILTIN_REGISTRY.get((list, method_name))
if builtin is not None:
if not self._prototype_is_intact(obj_type):
raise InterpreterError
result = builtin(obj, args)
if isinstance(obj, JsBuffer) and isinstance(result, list) and not isinstance(result, JsBuffer):
result = JsBuffer(result)
return result
if isinstance(obj, list) and method_name in _ARRAY_HOF_METHODS:
if not self._prototype_is_intact(obj_type):
raise InterpreterError
result = self._eval_array_hof(obj, method_name, args)
if isinstance(obj, JsBuffer) and method_name in _BUFFER_PRESERVING_HOFS:
if isinstance(result, list) and not isinstance(result, JsBuffer):
result = JsBuffer(result)
return result
if isinstance(obj, (JsFunctionExpression, JsArrowFunctionExpression)):
if method_name in ('call', 'apply') and not self._prototype_is_intact(obj_type):
raise InterpreterError
if method_name == 'call':
return self._call_function(obj, args[1:] if len(args) > 1 else [])
if method_name == 'apply':
actual_args = args[1] if len(args) > 1 and isinstance(args[1], list) else []
return self._call_function(obj, actual_args)
raise InterpreterError
def _builtins_intact(self) -> bool:
"""
Whether the tampering oracle vouches that no builtin was replaced before the anchor's
execution. An interpreter constructed with no anchor, or with no oracle beside it, answers
`False`: the trust questions below then fall through to their no-anchor arms, which refuse
wherever a reflective surface could have done the replacing.
"""
return (
self._tampering is not None
and self._anchor is not None
and self._tampering.builtins_intact_at(self._anchor)
)
def _callee_is_intact(self, node: JsCallExpression) -> bool:
"""
Whether the built-in *node* names is still that built-in. Evaluating a call by looking its name up
in the registry assumes the program has not replaced it, and an obfuscated file may well have.
Without an effect model there is nothing to consult, and the interpreter is then used on a single
expression in isolation rather than over a whole program, so the assumption is the caller's.
Where the oracle has vouched for the anchor, the reflection terms of the name question are
answered already, so the attributed writes alone decide — `call_names_an_unwritten_builtin`.
"""
effects = self._effects
if effects is None:
return True
if self._builtins_intact():
return effects.call_names_an_unwritten_builtin(node)
return effects.call_is_foldable(node)
def _require_uninterceptable(self, value: Value) -> None:
"""
Refuse where converting *value* to the primitive an operator, a key, or a template hole needs
could run a conversion the program installed. `coerces_uninterceptably` decides, against the
effect model this interpreter holds — or its surface-free half, where the oracle has vouched
for the anchor; without an effect model there is no chain to vouch for an object,
so every object conversion refuses rather than assuming a file that replaced none.
"""
if not self._coerces_uninterceptably(value):
raise InterpreterError
def _coerces_uninterceptably(self, value: Value) -> bool:
"""
The coercion guard the operator, key, and template arms ask: `coerces_uninterceptably`, or
its written-chain half where the oracle has vouched for the anchor.
"""
if self._builtins_intact():
return coerces_uninterceptably_from_written_chain(self._effects, value)
return coerces_uninterceptably(self._effects, value)
def _prototype_is_intact(self, value_type: type) -> bool:
"""
Whether the prototype that supplies *value_type*'s methods is unmodified, so dispatching a method
on a receiver of that type by name still means what the language says. This is the same question
`_callee_is_intact` asks, for a receiver that names no global; where the oracle has vouched for
the anchor, the attributed writes alone decide.
"""
effects = self._effects
if effects is None:
return True
if self._builtins_intact():
return effects.prototype_name_unwritten(value_type)
return effects.trusted_prototype(value_type)
def _chain_is_intact(self, value_type: type) -> bool:
"""
Whether every prototype a plain property read on a value of *value_type* consults is
unmodified — `EffectModel.read_chain_intact`, or its surface-free half where the oracle has
vouched for the anchor. The question the for-in and for-of walks and the inherited-read
arms ask; without an effect model there is no chain to consult and the caller owns the
assumption.
"""
effects = self._effects
if effects is None:
return True
if self._builtins_intact():
return effects.chain_roots_unwritten(value_type)
return effects.read_chain_intact(value_type)
def _callback_is_contained(self, callback: JsFunctionNode) -> bool:
"""
Whether running *callback* for its return values alone loses nothing. A higher-order method is
evaluated here for the value it produces, so a callback that also writes a binding outside itself
has an effect the resulting literal cannot carry: `[1,2].map(function (x) { n += x; return x; })`
yields the right array while leaving `n` unchanged.
It asks `is_effect_free_when_discarded` rather than `is_pure`, which is stricter than this
position needs: a mutation the callback confines to a fresh local it returns is tolerated, while
a throw the summary records in `throws` is not. Purity alone is not sufficient either — a write
to a *script-scope* `var` reports `writes_captured=False`, slipping through every purity flag —
so `written_bindings`, which records outer bindings by identity, is checked too.
"""
effects = self._effects
if effects is None:
return True
summary = effects.summary_of(callback)
return summary.is_effect_free_when_discarded and not summary.written_bindings
def _eval_array_hof(self, arr: list, method: str, args: list[Value]) -> Value:
if not args:
raise InterpreterError
callback = args[0]
if not isinstance(
callback,
(JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)
):
raise InterpreterError
if not self._callback_is_contained(callback):
raise InterpreterError
# Every higher-order method asks the array whether it has a property at each index
# before it calls, so a hole is a position no callback runs at and no filter keeps.
# `find` and `findIndex` are the exception: they read the element directly and visit a
# hole as the `undefined` the read yields.
if method == 'every':
for i, item in enumerate(arr):
if item is JS_HOLE:
continue
self._tick()
if not to_boolean(self._call_function(callback, [item, i, arr])):
return False
return True
if method == 'some':
for i, item in enumerate(arr):
if item is JS_HOLE:
continue
self._tick()
if to_boolean(self._call_function(callback, [item, i, arr])):
return True
return False
if method == 'map':
mapped: list[Value] = []
for i, item in enumerate(arr):
if item is JS_HOLE:
mapped.append(JS_HOLE)
continue
self._tick()
mapped.append(self._call_function(callback, [item, i, arr]))
return mapped
if method == 'filter':
filtered: list[Value] = []
for i, item in enumerate(arr):
if item is JS_HOLE:
continue
self._tick()
if to_boolean(self._call_function(callback, [item, i, arr])):
filtered.append(item)
return filtered
if method == 'find':
for i, item in enumerate(arr):
self._tick()
if to_boolean(self._call_function(callback, [_element_read(item), i, arr])):
return _element_read(item)
return None
if method == 'findIndex':
for i, item in enumerate(arr):
self._tick()
if to_boolean(self._call_function(callback, [_element_read(item), i, arr])):
return i
return -1
if method == 'forEach':
for i, item in enumerate(arr):
if item is JS_HOLE:
continue
self._tick()
self._call_function(callback, [item, i, arr])
return None
if method == 'reduce':
if len(args) >= 2:
acc: Value = args[1]
start = 0
else:
start = 0
while start < len(arr) and arr[start] is JS_HOLE:
start += 1
if start == len(arr):
_js_throw('TypeError', 'Reduce of empty array with no initial value')
acc = arr[start]
start += 1
for i in range(start, len(arr)):
if arr[i] is JS_HOLE:
continue
self._tick()
acc = self._call_function(callback, [acc, arr[i], i, arr])
return acc
raise InterpreterError
def _eval_inline_call(self, func, arguments: list) -> Value:
args = [self._eval(a) for a in arguments]
return self._call_function(func, args)
def _mutates_captured_binding(self, func) -> bool:
"""
Whether *func* assigns to a name that the calling environment binds but that *func* does not
declare locally — a write through a closure into a captured outer variable. A nested call runs
in an isolated child interpreter with only a snapshot of captured values and no write-back, so
the mutation would be silently lost. Refusing to evaluate leaves the call in place for a real
engine to run and keeps the fold sound rather than producing a wrong constant.
"""
body = func.body
if not isinstance(body, JsBlockStatement):
return False
local_names: set[str] = {p.name for p in func.params if isinstance(p, JsIdentifier)}
if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
for node in walk_scope(body):
if isinstance(node, JsVariableDeclaration):
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
local_names.add(decl.id.name)
elif isinstance(node, JsFunctionDeclaration) and isinstance(node.id, JsIdentifier):
local_names.add(node.id.name)
for node in walk_scope(body):
if isinstance(node, JsAssignmentExpression) and isinstance(node.left, JsIdentifier):
name = node.left.name
elif isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier):
name = node.argument.name
else:
continue
if name not in local_names and name in self._env:
return True
return False
def _call_function(self, func: JsFunctionNode, args: list[Value]) -> Value:
"""
The value a call to *func* answers, computed by a child interpreter. What a call answers at
all is `execute`'s question and is refused there, so that the refusal covers the entry a
caller outside this class reaches as well.
"""
if self._depth >= self.max_recursion:
raise InterpreterError
if self._mutates_captured_binding(func):
raise InterpreterError
callee_closure = self._closure_env.get(id(func)) or {}
child = JsInterpreter(
max_iterations=max(1, self.max_iterations - self._iterations),
max_string_len=self.max_string_len,
max_array_len=self.max_array_len,
max_recursion=self.max_recursion,
effects=self._effects,
model=self._model,
anchor=self._anchor,
tampering=self._tampering,
closure=callee_closure,
closure_env=self._closure_env,
established=self._established,
depth=self._depth + 1,
)
try:
result = child.execute(func, args)
finally:
self._iterations += child._iterations
return result
def _eval_member(self, node: JsMemberExpression) -> Value:
if isinstance(node.object, JsIdentifier) and node.object.name in STATIC_OBJECTS:
raise InterpreterError
obj = self._eval(node.object)
if node.optional and (obj is None or obj is JS_NULL):
return None
key = self._member_key(node)
if isinstance(obj, JsArguments):
return self._read_arguments_element(node, obj, key)
return self._get_property(obj, key)
def _read_arguments_element(
self, node: JsMemberExpression, arguments: JsArguments, key: str,
) -> Value:
"""
The element or the element count the arguments object answers for *key*. Every other key
refuses rather than guessing, for the same reason a method read off any other value does:
the answer lives on the prototype chain, on a `callee` no value domain here holds, or at
an index past the end the chain may supply, and none of those is this object's to decide.
"""
if key == 'length':
return len(arguments.values)
index = canonical_array_index(key)
if index is not None and 0 <= index < len(arguments.values):
return arguments.values[index]
raise IrreducibleExpression(node)
def _eval_template(self, node: JsTemplateLiteral) -> Value:
"""
A template denotes the text of its runs with what its holes evaluate to between them. A run
that denotes nothing makes the whole literal denote nothing: the language refuses such a
template rather than reading it, so there is no string to hand back and computing one would
answer for a script no engine will run.
A hole's value is converted to a string, so it asks the coercion guard first: a program
that installed a conversion on the object the hole evaluates to decides what the template
spells, and that is a program this interpreter refuses to answer for.
"""
parts: list[str] = []
for i, quasi in enumerate(node.quasis):
if quasi.value is None:
raise IrreducibleExpression(node)
parts.append(quasi.value)
if i < len(node.expressions):
value = self._eval(node.expressions[i])
self._require_uninterceptable(value)
parts.append(to_string(value))
result = ''.join(parts)
if len(result) > self.max_string_len:
raise InterpreterError
return result
def _eval_object(self, node: JsObjectExpression) -> Value:
"""
Evaluate an object literal into a dict of its own data properties. A literal that installs a
prototype through the plain `__proto__:` form has a member surface this dict cannot express —
the installed object's properties are inherited, not owned — so it aborts interpretation rather
than record `__proto__` as an ordinary key, which would invent an own property JavaScript does
not create. `object_sets_prototype` decides which spellings do that; the computed form and a
shorthand or method define an ordinary own property and evaluate normally.
"""
if object_sets_prototype(node):
raise InterpreterError
result: dict[str, Value] = {}
for prop in node.properties:
if not isinstance(prop, JsProperty):
raise InterpreterError
if prop.kind != JsPropertyKind.INIT:
raise InterpreterError
key: str
if prop.computed:
key = to_string(self._eval(prop.key))
elif isinstance(prop.key, JsIdentifier):
key = prop.key.name
elif isinstance(prop.key, JsStringLiteral):
if prop.key.value is None:
raise InterpreterError
key = prop.key.value
elif isinstance(prop.key, JsNumericLiteral):
key = to_string(prop.key.value)
else:
raise InterpreterError
result[key] = self._eval(prop.value)
return result
def _member_key(self, node: JsMemberExpression) -> str:
if node.computed:
val = self._eval(node.property)
self._require_uninterceptable(val)
return to_string(val)
if isinstance(node.property, JsIdentifier):
return node.property.name
raise InterpreterError
def _get_property(self, obj: Value, key: str) -> Value:
"""
Read property *key* off *obj*, as a property *read* rather than a call. A method name yields the
method itself, which this interpreter has no value domain for — a JS function value has an
observable identity, `name`, `length`, and source text — so reading one aborts interpretation
instead of guessing. Crucially it must not *invoke* the method: `typeof 'abc'.charAt` is
`'function'`, not the `typeof` of what `charAt()` would return.
Which keys the value itself decides is `read_data_property`'s to say, so that an emulated
execution and a fold read a property the same way by construction. What is left here is the
half that needs this interpreter: a nullish receiver throws before any key is looked at, and
a key the value does not own is `undefined` only while the prototype chain is intact.
An index past the end of an array is such a key and not a value of its own. Writing
`Array.prototype[5]` puts a value where the array has no slot, so a read of `a[5]` answers
it, and both of the outcomes the value declines to answer therefore ask the same question:
the one that names an index is not the shorter road to `undefined` it looks like.
"""
if obj is None or obj is JS_NULL:
_js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading '{key}')")
outcome, value = read_data_property(obj, key)
if outcome is MemberRead.FOUND:
return value
if self._property_is_absent(obj, key):
return None
raise InterpreterError
def _property_is_absent(self, obj: Value, key: str) -> bool:
"""
Whether *key* provably does not exist anywhere on *obj*'s prototype chain, making a read of it
`undefined`. `property_provably_absent` decides it, so that an emulated execution and a fold
answer an inherited read the same way by construction; what is left here is naming the type
this interpreter's value is of and handing over the effect model when there is one. Where
the oracle has vouched for the anchor, the chain is asked its attributed writes alone
(`property_absent_from_written_chain`) rather than refused on a reflective surface.
"""
if self._builtins_intact():
return property_absent_from_written_chain(self._effects, type(obj), key)
return property_provably_absent(self._effects, type(obj), key)
def _set_property(self, obj: Value, key: str, value: Value) -> None:
"""
Store *value* at *key* on *obj*, or refuse when this interpreter cannot model what the store means.
An array accepts only `length` and a canonical index. Refusing every *named* property on one is not
merely a gap in coverage: it is what keeps a function that writes `constructor` or `__proto__` on an
array unfoldable. Those two properties decide what `slice` and its neighbours return — the read goes
through ArraySpeciesCreate — so a program that sets them can make an apparently fresh array be a
shared object, or make the call throw by leaving a primitive there. `EffectModel` refuses such a
receiver as well, and both refusals are wanted: a widening here that started modelling named array
properties would need that reasoning restated, not silently dropped.
Growing the array — storing past its end, or writing a larger `length` — happens in place, filling
the positions the store skipped over with holes, because the array another name holds must grow
with it: `var v = u; u[87] = 5` leaves `v.length` at 88, so a copy is not a modelling of that
store. How far an index or a length may reach is capped, which bounds the memory one store may
ask for; a store the cap refuses still refuses here.
"""
if isinstance(obj, dict):
if key == PROTO_KEY and key not in obj:
# Writing `__proto__` runs the accessor Object.prototype carries, which installs a
# prototype instead of creating an own property. An existing own `__proto__` entry
# shadows that accessor, and then the write does land in the data slot.
raise InterpreterError
obj[key] = value
return
if isinstance(obj, list):
if key in SEQUENCE_DATA_PROPERTIES:
new_len = _to_array_length(value)
if new_len > len(obj):
if new_len > self.max_array_len:
raise InterpreterError
obj.extend([JS_HOLE] * (new_len - len(obj)))
return
del obj[new_len:]
return
index = canonical_array_index(key)
if index is not None:
if index >= self.max_array_len:
raise InterpreterError
if index > len(obj):
obj.extend([JS_HOLE] * (index - len(obj)))
if index == len(obj):
obj.append(value)
else:
obj[index] = value
return
raise InterpreterError
@staticmethod
def _strict_equal(a: Value, b: Value) -> bool:
return js_strict_equal(a, b)
@staticmethod
def _loose_equal(a: Value, b: Value) -> bool:
"""
Replicate the ECMA-262 abstract-equality (`==`) algorithm. `null` and `undefined` are equal to
each other and to nothing else; a boolean coerces to a number; two objects compare by
reference, while an object compared with a primitive coerces to a primitive first; a number
compared with a string compares by numeric value.
"""
a_nullish = a is None or a is JS_NULL
b_nullish = b is None or b is JS_NULL
if a_nullish or b_nullish:
return a_nullish and b_nullish
if isinstance(a, bool):
a = 1 if a else 0
if isinstance(b, bool):
b = 1 if b else 0
a_object = isinstance(a, _OBJECT_VALUE_TYPES)
b_object = isinstance(b, _OBJECT_VALUE_TYPES)
if a_object and b_object:
return js_strict_equal(a, b)
if a_object:
a = _to_primitive(a)
elif b_object:
b = _to_primitive(b)
if isinstance(a, str) and isinstance(b, str):
return a == b
a_num = isinstance(a, (int, float))
b_num = isinstance(b, (int, float))
if a_num and b_num:
return a == b
if (a_num and isinstance(b, str)) or (isinstance(a, str) and b_num):
return to_number(a) == to_number(b)
return js_strict_equal(a, b)
def eval_expression(self, expr) -> Value:
return self._eval(expr)
Functions
def js_strict_equal(a, b)-
Compare two interpreter values using JavaScript strict-equality (
===) semantics. Unlike Python equality this does not conflate booleans with the numbers1and0.Expand source code Browse git
def js_strict_equal(a: Value, b: Value) -> bool: """ Compare two interpreter values using JavaScript strict-equality (`===`) semantics. Unlike Python equality this does not conflate booleans with the numbers `1` and `0`. """ if isinstance(a, bool) or isinstance(b, bool): return a is b if a is None or b is None: return a is None and b is None if isinstance(a, (int, float)) and isinstance(b, (int, float)): return a == b if type(a) is not type(b): return False if isinstance(a, str): return a == b return a is b def is_runtime_name(name)-
Return True if
nameis a known JavaScript runtime symbol — either a static object namespace (e.g.Math,String) or a global function registered in the builtin registry (e.g.parseInt,parseFloat).Expand source code Browse git
def is_runtime_name(name: str) -> bool: """ Return True if `name` is a known JavaScript runtime symbol — either a static object namespace (e.g. `Math`, `String`) or a global function registered in the builtin registry (e.g. `parseInt`, `parseFloat`). """ return name in STATIC_OBJECTS or (None, name) in BUILTIN_REGISTRY def names_runtime_builtin(node, model)-
Whether node names a runtime symbol this interpreter models, read where nothing has bound the name. The spelling alone does not settle it:
catch (parseInt)andfunction o(parseInt)both give the name a value of their own, and evaluating a call to it as the built-in then computes an answer the program never produces.Expand source code Browse git
def names_runtime_builtin(node: JsIdentifier, model: SemanticModel) -> bool: """ Whether *node* names a runtime symbol this interpreter models, read where nothing has bound the name. The spelling alone does not settle it: `catch (parseInt)` and `function o(parseInt)` both give the name a value of their own, and evaluating a call to it as the built-in then computes an answer the program never produces. """ return is_runtime_name(node.name) and name_is_unbound(node, model)
Classes
class InterpreterError (*args, **kwargs)-
Common base class for all non-exit exceptions.
Expand source code Browse git
class InterpreterError(Exception): passAncestors
- builtins.Exception
- builtins.BaseException
class IrreducibleExpression (node)-
Common base class for all non-exit exceptions.
Expand source code Browse git
class IrreducibleExpression(Exception): def __init__(self, node: Node): self.node = nodeAncestors
- builtins.Exception
- builtins.BaseException
class JsArguments (values)-
The
argumentsobject of the call being interpreted: the argument values that call was made with, held read-only. Deliberately not alistsubclass — nothing that dispatches on a list may answer for it, no builtin registry entry exists for the type, and the object this models exposes onlylengthand the indexed elements. It cannot escape into a folded value: a body whoseargumentsreads are not all element-wise never passes the preflight that admits one, so no bareargumentsever reaches the value domain.Expand source code Browse git
class JsArguments: """ The `arguments` object of the call being interpreted: the argument values that call was made with, held read-only. Deliberately not a `list` subclass — nothing that dispatches on a list may answer for it, no builtin registry entry exists for the type, and the object this models exposes only `length` and the indexed elements. It cannot escape into a folded value: a body whose `arguments` reads are not all element-wise never passes the preflight that admits one, so no bare `arguments` ever reaches the value domain. """ __slots__ = ('values',) def __init__(self, values: list[Value]): self.values = valuesInstance variables
var values-
Expand source code Browse git
class JsArguments: """ The `arguments` object of the call being interpreted: the argument values that call was made with, held read-only. Deliberately not a `list` subclass — nothing that dispatches on a list may answer for it, no builtin registry entry exists for the type, and the object this models exposes only `length` and the indexed elements. It cannot escape into a folded value: a body whose `arguments` reads are not all element-wise never passes the preflight that admits one, so no bare `arguments` ever reaches the value domain. """ __slots__ = ('values',) def __init__(self, values: list[Value]): self.values = values
class JsInterpreter (*, max_iterations=100000, max_string_len=1000000, max_array_len=1000000, max_recursion=10, effects=None, model=None, anchor=None, tampering=None, closure=None, closure_env=None, established=None, depth=0)-
Execute a JavaScript function body with concrete argument values. Returns a Python value or raises
IrreducibleExpressionwhen the return value cannot be reduced to a simple value.Expand source code Browse git
class JsInterpreter: """ Execute a JavaScript function body with concrete argument values. Returns a Python value or raises `IrreducibleExpression` when the return value cannot be reduced to a simple value. """ def __init__( self, *, max_iterations: int = MAX_ITERATIONS, max_string_len: int = MAX_STRING_LEN, max_array_len: int = MAX_ARRAY_LEN, max_recursion: int = _MAX_RECURSION, effects: EffectModel | None = None, model: SemanticModel | None = None, anchor: Node | None = None, tampering: TamperingModel | None = None, closure: Mapping[str, Value] | None = None, closure_env: Mapping[int, Mapping[str, Value]] | None = None, established: Callable[[JsFunctionNode], bool] | None = None, depth: int = 0, ): self.max_iterations = max_iterations self.max_string_len = max_string_len self.max_array_len = max_array_len self.max_recursion = max_recursion self._effects = effects self._model = model if model is not None else effects and effects.model self._anchor = anchor self._tampering = tampering """ The scope authority, held separately from *effects*. Whether a name still reaches the host is a question about bindings alone, so a caller that has only the semantic model — reflection builds one and would pay for an effect model it never consults — can answer it by passing *model*. """ self._closure: Mapping[str, Value] = closure or {} self._closure_env: Mapping[int, Mapping[str, Value]] = closure_env or {} self._established = established self._env: dict[str, Value] = {} self._iterations = 0 self._depth = depth def execute( self, func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression, arguments: list[Value], ) -> Value: """ The value a call to *func* with *arguments* answers, which is the value its body returned only where a call answers that value at all. An `async` function answers a promise and a generator answers a generator object whose body has not run, so `wraps_return` ends the interpretation rather than reporting a value the call never had. `InterpreterError` is what ends it and not `IrreducibleExpression`: the latter hands the caller the body's return expression to splice into the call site, which is the same wrong answer written a second way. A body declaring a function anywhere but among its own statements ends it too. This interpreter has one environment per call and none per block, so it holds such a function from the entry of the body, where the language holds it in the block it is written in and, in sloppy code, in the enclosing scope only from the point the declaration runs. A read before that point is the difference, and it is one this answers with the function where a program answers `undefined`. """ if wraps_return(func): raise InterpreterError if _declares_a_function_inside_a_block(func): raise InterpreterError params = func.params param_names: list[str] = [] for p in params: if not isinstance(p, JsIdentifier): raise InterpreterError param_names.append(p.name) self._env = {} for i, name in enumerate(param_names): self._env[name] = arguments[i] if i < len(arguments) else None body = func.body for name in self._collect_hoisted_var_names(body): if name == 'arguments' and not isinstance(func, JsArrowFunctionExpression): # The call has already bound `arguments` to the argument object before any # statement runs, so an entry holding `undefined` would answer a read with a # value the call never made — including under a `var` that displaces it, where # leaving the name unbound refuses instead. continue self._env.setdefault(name, None) arguments_object = self._arguments_object(func, arguments) if arguments_object is not None: self._env['arguments'] = arguments_object for name, value in self._closure.items(): if name not in self._env: self._env[name] = _deep_copy_value(value) self._iterations = 0 if isinstance(body, JsBlockStatement): try: self._exec_statements(body.body) except _ReturnSignal as r: return r.value except _ReturnIrreducible as r: raise IrreducibleExpression(r.node) except IrreducibleExpression: raise InterpreterError except _ThrowSignal: if self._depth == 0: raise InterpreterError raise return None if body is not None: try: return self._eval(body) except IrreducibleExpression: raise IrreducibleExpression(body) except _ThrowSignal: if self._depth == 0: raise InterpreterError raise return None @staticmethod def _collect_hoisted_var_names(body) -> list[str]: """ Collect the names of all `var` declarations in *body*, which JavaScript hoists to the top of the function scope (initialized to `undefined`). Nested function bodies are not traversed. Reading a hoisted name before its initializer must yield `undefined`, not an unresolved free identifier. """ if not isinstance(body, JsBlockStatement): return [] names: list[str] = [] for node in walk_scope(body, include_root_body=True): if isinstance(node, JsVariableDeclaration) and node.kind == JsVarKind.VAR: for decl in node.declarations: if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): names.append(decl.id.name) return names def _arguments_object(self, func, values: list[Value]) -> JsArguments | None: """ The `arguments` object a call to *func* may be given, or `None` where the body may not be handed one. Whether the object a call models from its argument values can stand in for the name is `call_supplies_an_arguments_object`'s question, shared with the fold that asks which names a call supplies. Every failure leaves the name unbound, so the read declines through the `IrreducibleExpression` every unresolved name takes, and a nested function's own `arguments` declines the same way in its own environment. """ model = self._model if model is None or not call_supplies_an_arguments_object(model, func): return None return JsArguments(values) def _exec_statements(self, stmts: list) -> None: for stmt in stmts: self._exec_statement(stmt) def _exec_statement(self, stmt) -> None: if isinstance(stmt, JsVariableDeclaration): self._exec_var_decl(stmt) elif isinstance(stmt, JsExpressionStatement): self._eval(stmt.expression) elif isinstance(stmt, JsIfStatement): self._exec_if(stmt) elif isinstance(stmt, JsSwitchStatement): self._exec_switch(stmt) elif isinstance(stmt, JsForStatement): self._exec_for(stmt) elif isinstance(stmt, JsWhileStatement): self._exec_while(stmt) elif isinstance(stmt, JsDoWhileStatement): self._exec_do_while(stmt) elif isinstance(stmt, JsForInStatement): self._exec_for_in(stmt) elif isinstance(stmt, JsForOfStatement): self._exec_for_of(stmt) elif isinstance(stmt, JsReturnStatement): if stmt.argument is None: raise _ReturnSignal(None) try: value = self._eval(stmt.argument) except IrreducibleExpression: raise _ReturnIrreducible(stmt.argument) raise _ReturnSignal(value) elif isinstance(stmt, JsBreakStatement): raise _BreakSignal elif isinstance(stmt, JsContinueStatement): raise _ContinueSignal elif isinstance(stmt, JsBlockStatement): self._exec_statements(stmt.body) elif isinstance(stmt, JsTryStatement): self._exec_try(stmt) elif isinstance(stmt, JsThrowStatement): value = self._eval(stmt.argument) if stmt.argument else None raise _ThrowSignal(value) elif isinstance(stmt, JsFunctionDeclaration): if isinstance(stmt.id, JsIdentifier): self._env[stmt.id.name] = stmt elif isinstance(stmt, JsEmptyStatement): return else: raise InterpreterError def _exec_var_decl(self, node: JsVariableDeclaration) -> None: for decl in node.declarations: if not isinstance(decl, JsVariableDeclarator): raise InterpreterError if not isinstance(decl.id, JsIdentifier): raise InterpreterError name = decl.id.name if decl.init is not None: self._env[name] = self._eval(decl.init) elif node.kind == JsVarKind.VAR: self._env.setdefault(name, None) else: self._env[name] = None def _exec_if(self, node: JsIfStatement) -> None: if to_boolean(self._eval(node.test)): if node.consequent: self._exec_statement(node.consequent) elif node.alternate: self._exec_statement(node.alternate) def _exec_switch(self, node: JsSwitchStatement) -> None: """ A switch runs the clause whose test the discriminant matches, and where none matches, the default clause. The default is chosen only after every test has been evaluated, so a clause written after it is still asked. Writing the default in the middle of a dispatcher is a way to make the clause order look like the run order when it is not, and reading the default the moment it is reached answers with the wrong branch for every discriminant a later clause would have claimed. Once a clause is chosen, execution enters it and falls through the clauses behind it, which is why the run is a walk from the chosen index rather than of the clause alone. Tests behind the match are not evaluated: a match ends the search, and an expression that would throw is never reached. A second default clause is an early error, so a switch carrying one is not a program and is refused rather than interpreted as though the first of them governed. """ cases = [case for case in node.cases if isinstance(case, JsSwitchCase)] if len(cases) != len(node.cases): raise InterpreterError defaults = [index for index, case in enumerate(cases) if case.test is None] if len(defaults) > 1: raise InterpreterError discriminant = self._eval(node.discriminant) chosen = None for index, case in enumerate(cases): if case.test is None: continue if self._strict_equal(discriminant, self._eval(case.test)): chosen = index break if chosen is None: if not defaults: return chosen = defaults[0] try: for case in cases[chosen:]: self._exec_statements(case.body) except _BreakSignal: return def _exec_loop_body(self, body) -> bool: if not body: return False try: self._exec_statement(body) except _BreakSignal: return True except _ContinueSignal: pass return False def _exec_for(self, node: JsForStatement) -> None: if node.init: if isinstance(node.init, JsVariableDeclaration): self._exec_var_decl(node.init) else: self._eval(node.init) while True: self._tick() if node.test and not to_boolean(self._eval(node.test)): break if self._exec_loop_body(node.body): break if node.update: self._eval(node.update) def _exec_while(self, node: JsWhileStatement) -> None: while True: self._tick() if not to_boolean(self._eval(node.test)): break if self._exec_loop_body(node.body): break def _exec_do_while(self, node: JsDoWhileStatement) -> None: while True: self._tick() if self._exec_loop_body(node.body): break if not to_boolean(self._eval(node.test)): break def _exec_for_in(self, node: JsForInStatement) -> None: """ Walk the enumerable names of the receiver, which is every name it owns and every enumerable one its prototype chain holds. The chain contributes none of its own while the program leaves it alone: measured across sixteen receiver kinds, every name the language installs on a prototype — and every method a class body writes onto one — is non-enumerable. So the own names are the whole answer exactly while the chain is intact, and where it is not, the walk is refused rather than answered a name short. Refusing one walk is what that costs, which is why this asks `read_chain_intact` rather than the arm that overlooks a reflective surface. The question asked is about the whole chain rather than about one key, which over-refuses where an own name shadows an added inherited one: `Object.prototype.z = 9` over a receiver holding its own `z` walks `z` once either way. That costs a fold and never an answer. """ right = self._eval(node.right) if right is None or right is JS_NULL: return if not isinstance(right, (dict, list)): raise InterpreterError if not self._chain_is_intact(type(right)): raise InterpreterError keys: list if isinstance(right, dict): keys = own_property_keys(right) else: keys = [str(i) for i, item in enumerate(right) if item is not JS_HOLE] var_name = self._get_loop_var(node.left) for key in keys: self._tick() self._env[var_name] = key if self._exec_loop_body(node.body): break def _exec_for_of(self, node: JsForOfStatement) -> None: """ Walk the values the receiver's iterator yields, which is the iterator a list's or a string's prototype supplies — so the walk asks the same chain question a for-in walk does before it yields anything: a program that patched `Array.prototype[Symbol.iterator]` makes the real loop answer something other than the elements, and an interpretation that walks them regardless is answering for it. """ right = self._eval(node.right) if right is None or right is JS_NULL: _js_throw('TypeError', F'{to_string(right)} is not iterable') if not isinstance(right, (list, str)): raise InterpreterError if not self._chain_is_intact(type(right)): raise InterpreterError if isinstance(right, list): # An array's iterator yields a hole as `undefined` — it asks whether the array has a # property at the index and takes the value on either branch — so a hole is not a skip. items = [None if item is JS_HOLE else item for item in right] else: items = code_points(right) var_name = self._get_loop_var(node.left) for item in items: self._tick() self._env[var_name] = item if self._exec_loop_body(node.body): break def _exec_try(self, node: JsTryStatement) -> None: thrown: _ThrowSignal | None = None propagate: Exception | None = None try: if node.block: self._exec_statements(node.block.body) except _ThrowSignal as exc: thrown = exc except ( IrreducibleExpression, InterpreterError, _ReturnSignal, _BreakSignal, _ContinueSignal, _ReturnIrreducible, ) as exc: propagate = exc if propagate is not None: if node.finalizer: self._exec_statements(node.finalizer.body) raise propagate if thrown is not None: if node.handler and node.handler.body: param_name: str | None = None had_param: bool = False prev_param: Value = None if isinstance(node.handler.param, JsIdentifier): param_name = node.handler.param.name had_param = param_name in self._env prev_param = self._env.get(param_name) self._env[param_name] = thrown.value handler_outcome: Exception | None = None try: self._exec_statements(node.handler.body.body) except ( _ThrowSignal, IrreducibleExpression, InterpreterError, _ReturnSignal, _BreakSignal, _ContinueSignal, _ReturnIrreducible, ) as exc: handler_outcome = exc finally: if param_name is not None: if had_param: self._env[param_name] = prev_param else: self._env.pop(param_name, None) if node.finalizer: self._exec_statements(node.finalizer.body) if handler_outcome is not None: raise handler_outcome return if node.finalizer: self._exec_statements(node.finalizer.body) raise thrown if node.finalizer: self._exec_statements(node.finalizer.body) def _get_loop_var(self, left) -> str: if isinstance(left, JsVariableDeclaration): if len(left.declarations) == 1: decl = left.declarations[0] if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): return decl.id.name if isinstance(left, JsIdentifier): return left.name raise InterpreterError def _tick(self) -> None: self._iterations += 1 if self._iterations > self.max_iterations: raise InterpreterError def _eval(self, expr) -> Value: if expr is None: return None if isinstance(expr, JsStringLiteral): if not expr.terminated or expr.value is None: raise IrreducibleExpression(expr) return expr.value if isinstance(expr, JsNumericLiteral): return expr.value if isinstance(expr, JsBooleanLiteral): return expr.value if isinstance(expr, JsNullLiteral): return JS_NULL if isinstance(expr, JsIdentifier): return self._eval_identifier(expr) if isinstance(expr, JsBinaryExpression): return self._eval_binary(expr) if isinstance(expr, JsUnaryExpression): return self._eval_unary(expr) if isinstance(expr, JsUpdateExpression): return self._eval_update(expr) if isinstance(expr, JsLogicalExpression): return self._eval_logical(expr) if isinstance(expr, JsAssignmentExpression): return self._eval_assignment(expr) if isinstance(expr, JsCallExpression): return self._eval_call(expr) if isinstance(expr, JsMemberExpression): return self._eval_member(expr) if isinstance(expr, JsConditionalExpression): test = self._eval(expr.test) return self._eval(expr.consequent) if to_boolean(test) else self._eval(expr.alternate) if isinstance(expr, JsArrayExpression): return self._eval_array(expr) if isinstance(expr, JsSequenceExpression): result: Value = None for e in expr.expressions: result = self._eval(e) return result if isinstance(expr, JsTemplateLiteral): return self._eval_template(expr) if isinstance(expr, JsObjectExpression): return self._eval_object(expr) if isinstance(expr, (JsFunctionExpression, JsArrowFunctionExpression)): return expr if isinstance(expr, JsParenthesizedExpression): return self._eval(expr.expression) raise InterpreterError def _resolve_function_node(self, node: JsIdentifier) -> JsFunctionNode | None: """ The single function *node* names, or `None`. Delegates to `EffectModel.unambiguous_function`: a function declaration or a bare-assignment (`var f; f = function(){}`) resolves, but a name reassigned away from a value it already held stays unresolved. When an *established* predicate was supplied, a resolved function is returned only if it is in place before the call site folding began — a bare-assignment or initializer function reached before its establishing node has run reads a temporal dead zone or a hoisted `undefined` at runtime, so resolving it here would replace that throw with a value. A hoisted function declaration is always established and passes unconditionally. """ effects = self._effects if effects is None: return None func = effects.unambiguous_function(effects.model.resolve(node)) if func is None: return None if self._established is not None and not self._established(func): return None return func def _names_a_runtime_builtin(self, node: JsIdentifier) -> bool: """ Whether *node* still reaches the host built-in of that name. Without a model this answers `False` and the call becomes irreducible, for the same reason `_names_a_global_value` does. """ model = self._model return model is not None and names_runtime_builtin(node, model) def _names_a_static_object(self, node: JsIdentifier) -> bool: """ Whether *node*, standing in receiver position ahead of a method the registry knows, still reaches the host object of that name. It is the question `_names_a_runtime_builtin` asks for a bare callee, and the static branch has to ask it too: `var Number = {parseInt: ...}` gives the name a value of its own, and answering the call from the registry then computes a number the program never produces. Without a model this answers `True` rather than `False`, matching `_callee_is_intact`: an interpreter used on one expression in isolation cannot see a scope, so the assumption is the caller's. """ if node.name in self._env: return False model = self._model return model is None or names_runtime_builtin(node, model) def _names_a_global_value(self, node: JsIdentifier) -> bool: """ Whether *node* is `undefined`, `NaN` or `Infinity` still denoting the global value, which only the semantic model can say. Without one this answers `False` and the name becomes irreducible: an interpreter that cannot see the scope cannot tell the global from a binding an enclosing function supplies, and guessing the global is how a call to `function f(NaN) { return NaN + 1 }` folded to `NaN` instead of to its argument. """ model = self._model return model is not None and names_global_value(node, model) def _resolves_to_a_binding(self, node: JsIdentifier) -> bool: """ Whether *node* resolves to any binding at all. A name the model binds but `_env` does not hold has a value this interpreter does not know — it may belong to an enclosing scope that is not in the closure, or to a `let` whose declarator has not run, which is a read in its temporal dead zone that throws — so the well-known-global `typeof` fallback must not answer for it. """ model = self._model return model is not None and model.resolve(node) is not None def _eval_identifier(self, node: JsIdentifier) -> Value: name = node.name if name in self._env: return self._env[name] func = self._resolve_function_node(node) if func is not None: return func if self._names_a_global_value(node): return GLOBAL_VALUE_NAMES[name] raise IrreducibleExpression(node) def _js_add(self, left: Value, right: Value) -> Value: """ Replicate the JavaScript `+` operator: apply ToPrimitive to both operands, then concatenate as strings if either is a string, otherwise add numerically. """ left = _to_primitive(left) right = _to_primitive(right) if isinstance(left, str) or isinstance(right, str): result = to_string(left) + to_string(right) if len(result) > self.max_string_len: raise InterpreterError return result return to_number(left) + to_number(right) def _eval_binary(self, node: JsBinaryExpression) -> Value: return self._apply_binary(node.operator, self._eval(node.left), self._eval(node.right)) def _apply_binary(self, op: str, left: Value, right: Value) -> Value: """ Apply the binary operator *op* to two already-evaluated values — the one place this interpreter decides what an operator means. A binary expression and the arithmetic step of a compound assignment ask the same question, so they must ask it here. `eval_binary_op` handles the numeric operators alone, which is why the string cases resolve first: `+` needs ToPrimitive on both operands and may yield a concatenation, and a relational operator compares two strings lexicographically rather than numerically. Everything after that is a number. Every operator but strict equality coerces its operands — `+` and the relational operators to primitives, the numeric ones to numbers, `==` to whichever the other side demands, and `in` its left side to a key — so every one of them asks the coercion guard first, the `in` operator of its key alone: an object whose conversion a program installed decides what all of these answer, and strict equality is the one operator that runs nothing. """ if op not in ('===', '!=='): self._require_uninterceptable(left) if op != 'in': self._require_uninterceptable(right) if op == '===': return self._strict_equal(left, right) if op == '!==': return not self._strict_equal(left, right) if op == '==': return self._loose_equal(left, right) if op == '!=': return not self._loose_equal(left, right) if op == '+': return self._js_add(left, right) if op == 'in': return self._eval_in(left, right) if op == 'instanceof': raise InterpreterError if op in RELATIONAL_OPS: primitive_left = _to_primitive(left) primitive_right = _to_primitive(right) if isinstance(primitive_left, str) and isinstance(primitive_right, str): return RELATIONAL_OPS[op](primitive_left, primitive_right) result = eval_binary_op(op, to_number(left), to_number(right)) if result is None: raise InterpreterError return result def _eval_in(self, left: Value, right: Value) -> bool: """ Whether *right* has a property named by *left*, which the operator answers for the whole prototype chain and not for the own properties alone. The three ways that can go are the three this file has words for: the value owns a slot there, the chain supplies the name, or neither does and it is `property_provably_absent`'s to say whether that settles anything. A name it will not decide without the chain is one this operator cannot answer `false` for either, so the interpretation stops rather than reporting what the own properties happen to show. `Object.prototype.z = 9` makes `'z' in {}` true, and reading a refusal as an absence is what a program written to be read wrongly is looking for. """ if not isinstance(right, (dict, list)): raise InterpreterError key = to_string(left) if read_data_property(right, key)[0] is MemberRead.FOUND: return True chain_intact = property_is_inherited_from_an_intact_chain(self._effects, type(right), key) if not chain_intact and self._builtins_intact(): chain_intact = property_is_inherited_from_an_unwritten_chain( self._effects, type(right), key) if chain_intact: return True if self._property_is_absent(right, key): return False raise InterpreterError def _eval_array(self, expr: JsArrayExpression) -> list[Value]: """ The list an array literal builds, refusing one that would hold a hole. An elision is not an element whose value is `undefined`: `[1, , 3][1]` reads `Array.prototype[1]` where `[1, undefined, 3][1]` reads the element, so the two answer differently wherever that prototype is written, and `indexOf` and the callback methods skip the first while visiting the second. A hole is representable in this domain — a store growing an array past its end builds one — but a literal holding one has no fold to go to: `value_to_node` refuses a holey list, so building it here would answer no call. The syntax model spells an elision as no element at all, which is the shape this refusal leaves standing. """ if any(element is None for element in expr.elements): raise InterpreterError return [self._eval(element) for element in expr.elements] def _eval_unary(self, node: JsUnaryExpression) -> Value: """ Apply a unary operator through `UNARY_OPS`, which holds the ones that are functions of the operand's value. `typeof` on a bare name is answered before the operand is evaluated because it is the one expression that reads a name without requiring it to exist: `typeof missing` is `'undefined'` where evaluating `missing` is a `ReferenceError`. """ op = node.operator operand = node.operand if op == 'typeof' and isinstance(operand, JsIdentifier): name = operand.name if name in self._env: return js_typeof(self._env[name]) if self._resolve_function_node(operand) is not None: return 'function' if self._resolves_to_a_binding(operand): raise IrreducibleExpression(node) environment = self._model.environment if self._model is not None else HostEnvironment.universal result = typeof_of_global(name, environment) if result is None: raise IrreducibleExpression(node) return result apply = UNARY_OPS.get(op) if apply is None: raise InterpreterError value = self._eval(operand) if op in ('+', '-', '~'): self._require_uninterceptable(value) return apply(value) def _eval_update(self, node: JsUpdateExpression) -> Value: """ Apply `++` or `--`. The target may be a plain identifier or a member, and both must be supported here: `a[i]++` is the same operation as `a[i] += 1` on a value the language has already coerced to a number, so refusing one while folding the other would make the two disagree. """ delta = {'++': 1, '--': -1}.get(node.operator) if delta is None: raise InterpreterError target = node.argument if isinstance(target, JsIdentifier): name = target.name if name not in self._env: raise InterpreterError self._require_uninterceptable(self._env[name]) current = to_number(self._env[name]) self._env[name] = current + delta elif isinstance(target, JsMemberExpression): obj = self._eval(target.object) key = self._member_key(target) current = self._get_property(obj, key) self._require_uninterceptable(current) current = to_number(current) self._set_property(obj, key, current + delta) else: raise InterpreterError return current + delta if node.prefix else current def _short_circuits(self, operator: str, current: Value) -> bool: """ Whether a logical assignment (`&&=`, `||=`, `??=`) is already decided by *current*, so neither its right operand nor the store runs. The three truthiness rules are the ones `_eval_logical` applies to the expression forms, read in the other direction. """ if operator == '&&=': return not to_boolean(current) if operator == '||=': return to_boolean(current) if operator == '??=': return current is not None and current is not JS_NULL raise InterpreterError def _compound_value(self, operator: str, current: Value, node: JsAssignmentExpression) -> Value: """ The value a compound assignment stores: its right operand evaluated and combined with *current* under the operator the assignment names. *current* is read by the caller before this runs, because JavaScript reads the target before evaluating the right operand — `v += (v = 10)` on `v = 5` is 15. """ return self._apply_binary(operator[:-1], current, self._eval(node.right)) def _eval_logical(self, node: JsLogicalExpression) -> Value: left = self._eval(node.left) if node.operator == '&&': return self._eval(node.right) if to_boolean(left) else left if node.operator == '||': return left if to_boolean(left) else self._eval(node.right) if node.operator == '??': if left is None or left is JS_NULL: return self._eval(node.right) return left raise InterpreterError def _eval_assignment(self, node: JsAssignmentExpression) -> Value: if isinstance(node.left, JsMemberExpression): return self._eval_member_assignment(node) if not isinstance(node.left, JsIdentifier): raise InterpreterError name = node.left.name op = node.operator if op == '=': value = self._eval(node.right) self._env[name] = value return value current = self._eval(node.left) if op in LOGICAL_ASSIGNMENT_OPS: if self._short_circuits(op, current): return current self._env[name] = self._eval(node.right) return self._env[name] self._env[name] = self._compound_value(op, current, node) return self._env[name] def _eval_member_assignment(self, node: JsAssignmentExpression) -> Value: """ Assign to a member target. The object and key expressions are evaluated exactly once, before the operator is considered, so `a[i++] += 1` advances `i` once — and a logical assignment that short-circuits performs no store at all. Skipping that store is what JavaScript specifies rather than an optimization: the language makes it observable through a setter or a frozen object, neither of which this interpreter's value domain has. Within this domain a store of the value just read is idempotent, so no program folded here can tell the difference — the rule is kept because the domain is a subset of the language's, not because a test can witness it. """ member = node.left if not isinstance(member, JsMemberExpression): raise InterpreterError obj = self._eval(member.object) key = self._member_key(member) if node.operator == '=': value = self._eval(node.right) elif node.operator in LOGICAL_ASSIGNMENT_OPS: old = self._get_property(obj, key) if self._short_circuits(node.operator, old): return old value = self._eval(node.right) else: value = self._compound_value(node.operator, self._get_property(obj, key), node) self._set_property(obj, key, value) return value def _eval_call(self, node: JsCallExpression) -> Value: if isinstance(node.callee, JsMemberExpression): return self._eval_method_call(node) if isinstance(node.callee, JsIdentifier): return self._eval_function_call(node) if isinstance(node.callee, (JsFunctionExpression, JsArrowFunctionExpression)): return self._eval_inline_call(node.callee, node.arguments) raise InterpreterError def _eval_function_call(self, node: JsCallExpression) -> Value: """ Call the function a bare name denotes. The registry is consulted last, and only for a name the scope has left alone: it records what the *host* provides, which is what a name reaches only when nothing nearer has claimed it. Asking it first is how a call to a parameter or a `catch` binding named `parseInt` was answered by the real `parseInt`. """ callee = node.callee if not isinstance(callee, JsIdentifier): raise InterpreterError name = callee.name args = [self._eval(a) for a in node.arguments] if name in self._env: target = self._env[name] if isinstance(target, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression)): return self._call_function(target, args) if node.optional and (target is None or target is JS_NULL): return None _js_throw('TypeError', F'{name} is not a function') func = self._resolve_function_node(callee) if func is not None: return self._call_function(func, args) builtin = BUILTIN_REGISTRY.get((None, name)) if builtin is not None and self._names_a_runtime_builtin(callee): if name in _COERCING_GLOBALS and not all( self._coerces_uninterceptably(arg) for arg in args ): raise InterpreterError return builtin(args) raise InterpreterError def _eval_method_call(self, node: JsCallExpression) -> Value: member = node.callee if not isinstance(member, JsMemberExpression): raise InterpreterError if ( isinstance(member.object, JsIdentifier) and member.object.name in STATIC_OBJECTS ): static_name = member.object.name method_name = self._member_key(member) args = [self._eval(a) for a in node.arguments] builtin = BUILTIN_REGISTRY.get((static_name, method_name)) if builtin is not None: if not self._names_a_static_object(member.object): raise InterpreterError if not self._callee_is_intact(node): raise InterpreterError return builtin(args) raise InterpreterError obj = self._eval(member.object) if obj is None or obj is JS_NULL: if member.optional: return None _js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading a method)") method_name = self._member_key(member) args = [self._eval(a) for a in node.arguments] if isinstance(obj, (str, list)) and method_name in SEQUENCE_DATA_PROPERTIES: # A data property is not callable. The arguments are evaluated first, as JS does, so a side # effect or a throw inside one is not lost to this TypeError. _js_throw('TypeError', F'{to_string(obj)}.{method_name} is not a function') obj_type = type(obj) builtin = BUILTIN_REGISTRY.get((obj_type, method_name)) if builtin is None and obj_type is not list and isinstance(obj, list): builtin = BUILTIN_REGISTRY.get((list, method_name)) if builtin is not None: if not self._prototype_is_intact(obj_type): raise InterpreterError result = builtin(obj, args) if isinstance(obj, JsBuffer) and isinstance(result, list) and not isinstance(result, JsBuffer): result = JsBuffer(result) return result if isinstance(obj, list) and method_name in _ARRAY_HOF_METHODS: if not self._prototype_is_intact(obj_type): raise InterpreterError result = self._eval_array_hof(obj, method_name, args) if isinstance(obj, JsBuffer) and method_name in _BUFFER_PRESERVING_HOFS: if isinstance(result, list) and not isinstance(result, JsBuffer): result = JsBuffer(result) return result if isinstance(obj, (JsFunctionExpression, JsArrowFunctionExpression)): if method_name in ('call', 'apply') and not self._prototype_is_intact(obj_type): raise InterpreterError if method_name == 'call': return self._call_function(obj, args[1:] if len(args) > 1 else []) if method_name == 'apply': actual_args = args[1] if len(args) > 1 and isinstance(args[1], list) else [] return self._call_function(obj, actual_args) raise InterpreterError def _builtins_intact(self) -> bool: """ Whether the tampering oracle vouches that no builtin was replaced before the anchor's execution. An interpreter constructed with no anchor, or with no oracle beside it, answers `False`: the trust questions below then fall through to their no-anchor arms, which refuse wherever a reflective surface could have done the replacing. """ return ( self._tampering is not None and self._anchor is not None and self._tampering.builtins_intact_at(self._anchor) ) def _callee_is_intact(self, node: JsCallExpression) -> bool: """ Whether the built-in *node* names is still that built-in. Evaluating a call by looking its name up in the registry assumes the program has not replaced it, and an obfuscated file may well have. Without an effect model there is nothing to consult, and the interpreter is then used on a single expression in isolation rather than over a whole program, so the assumption is the caller's. Where the oracle has vouched for the anchor, the reflection terms of the name question are answered already, so the attributed writes alone decide — `call_names_an_unwritten_builtin`. """ effects = self._effects if effects is None: return True if self._builtins_intact(): return effects.call_names_an_unwritten_builtin(node) return effects.call_is_foldable(node) def _require_uninterceptable(self, value: Value) -> None: """ Refuse where converting *value* to the primitive an operator, a key, or a template hole needs could run a conversion the program installed. `coerces_uninterceptably` decides, against the effect model this interpreter holds — or its surface-free half, where the oracle has vouched for the anchor; without an effect model there is no chain to vouch for an object, so every object conversion refuses rather than assuming a file that replaced none. """ if not self._coerces_uninterceptably(value): raise InterpreterError def _coerces_uninterceptably(self, value: Value) -> bool: """ The coercion guard the operator, key, and template arms ask: `coerces_uninterceptably`, or its written-chain half where the oracle has vouched for the anchor. """ if self._builtins_intact(): return coerces_uninterceptably_from_written_chain(self._effects, value) return coerces_uninterceptably(self._effects, value) def _prototype_is_intact(self, value_type: type) -> bool: """ Whether the prototype that supplies *value_type*'s methods is unmodified, so dispatching a method on a receiver of that type by name still means what the language says. This is the same question `_callee_is_intact` asks, for a receiver that names no global; where the oracle has vouched for the anchor, the attributed writes alone decide. """ effects = self._effects if effects is None: return True if self._builtins_intact(): return effects.prototype_name_unwritten(value_type) return effects.trusted_prototype(value_type) def _chain_is_intact(self, value_type: type) -> bool: """ Whether every prototype a plain property read on a value of *value_type* consults is unmodified — `EffectModel.read_chain_intact`, or its surface-free half where the oracle has vouched for the anchor. The question the for-in and for-of walks and the inherited-read arms ask; without an effect model there is no chain to consult and the caller owns the assumption. """ effects = self._effects if effects is None: return True if self._builtins_intact(): return effects.chain_roots_unwritten(value_type) return effects.read_chain_intact(value_type) def _callback_is_contained(self, callback: JsFunctionNode) -> bool: """ Whether running *callback* for its return values alone loses nothing. A higher-order method is evaluated here for the value it produces, so a callback that also writes a binding outside itself has an effect the resulting literal cannot carry: `[1,2].map(function (x) { n += x; return x; })` yields the right array while leaving `n` unchanged. It asks `is_effect_free_when_discarded` rather than `is_pure`, which is stricter than this position needs: a mutation the callback confines to a fresh local it returns is tolerated, while a throw the summary records in `throws` is not. Purity alone is not sufficient either — a write to a *script-scope* `var` reports `writes_captured=False`, slipping through every purity flag — so `written_bindings`, which records outer bindings by identity, is checked too. """ effects = self._effects if effects is None: return True summary = effects.summary_of(callback) return summary.is_effect_free_when_discarded and not summary.written_bindings def _eval_array_hof(self, arr: list, method: str, args: list[Value]) -> Value: if not args: raise InterpreterError callback = args[0] if not isinstance( callback, (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression) ): raise InterpreterError if not self._callback_is_contained(callback): raise InterpreterError # Every higher-order method asks the array whether it has a property at each index # before it calls, so a hole is a position no callback runs at and no filter keeps. # `find` and `findIndex` are the exception: they read the element directly and visit a # hole as the `undefined` the read yields. if method == 'every': for i, item in enumerate(arr): if item is JS_HOLE: continue self._tick() if not to_boolean(self._call_function(callback, [item, i, arr])): return False return True if method == 'some': for i, item in enumerate(arr): if item is JS_HOLE: continue self._tick() if to_boolean(self._call_function(callback, [item, i, arr])): return True return False if method == 'map': mapped: list[Value] = [] for i, item in enumerate(arr): if item is JS_HOLE: mapped.append(JS_HOLE) continue self._tick() mapped.append(self._call_function(callback, [item, i, arr])) return mapped if method == 'filter': filtered: list[Value] = [] for i, item in enumerate(arr): if item is JS_HOLE: continue self._tick() if to_boolean(self._call_function(callback, [item, i, arr])): filtered.append(item) return filtered if method == 'find': for i, item in enumerate(arr): self._tick() if to_boolean(self._call_function(callback, [_element_read(item), i, arr])): return _element_read(item) return None if method == 'findIndex': for i, item in enumerate(arr): self._tick() if to_boolean(self._call_function(callback, [_element_read(item), i, arr])): return i return -1 if method == 'forEach': for i, item in enumerate(arr): if item is JS_HOLE: continue self._tick() self._call_function(callback, [item, i, arr]) return None if method == 'reduce': if len(args) >= 2: acc: Value = args[1] start = 0 else: start = 0 while start < len(arr) and arr[start] is JS_HOLE: start += 1 if start == len(arr): _js_throw('TypeError', 'Reduce of empty array with no initial value') acc = arr[start] start += 1 for i in range(start, len(arr)): if arr[i] is JS_HOLE: continue self._tick() acc = self._call_function(callback, [acc, arr[i], i, arr]) return acc raise InterpreterError def _eval_inline_call(self, func, arguments: list) -> Value: args = [self._eval(a) for a in arguments] return self._call_function(func, args) def _mutates_captured_binding(self, func) -> bool: """ Whether *func* assigns to a name that the calling environment binds but that *func* does not declare locally — a write through a closure into a captured outer variable. A nested call runs in an isolated child interpreter with only a snapshot of captured values and no write-back, so the mutation would be silently lost. Refusing to evaluate leaves the call in place for a real engine to run and keeps the fold sound rather than producing a wrong constant. """ body = func.body if not isinstance(body, JsBlockStatement): return False local_names: set[str] = {p.name for p in func.params if isinstance(p, JsIdentifier)} if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier): local_names.add(func.id.name) for node in walk_scope(body): if isinstance(node, JsVariableDeclaration): for decl in node.declarations: if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier): local_names.add(decl.id.name) elif isinstance(node, JsFunctionDeclaration) and isinstance(node.id, JsIdentifier): local_names.add(node.id.name) for node in walk_scope(body): if isinstance(node, JsAssignmentExpression) and isinstance(node.left, JsIdentifier): name = node.left.name elif isinstance(node, JsUpdateExpression) and isinstance(node.argument, JsIdentifier): name = node.argument.name else: continue if name not in local_names and name in self._env: return True return False def _call_function(self, func: JsFunctionNode, args: list[Value]) -> Value: """ The value a call to *func* answers, computed by a child interpreter. What a call answers at all is `execute`'s question and is refused there, so that the refusal covers the entry a caller outside this class reaches as well. """ if self._depth >= self.max_recursion: raise InterpreterError if self._mutates_captured_binding(func): raise InterpreterError callee_closure = self._closure_env.get(id(func)) or {} child = JsInterpreter( max_iterations=max(1, self.max_iterations - self._iterations), max_string_len=self.max_string_len, max_array_len=self.max_array_len, max_recursion=self.max_recursion, effects=self._effects, model=self._model, anchor=self._anchor, tampering=self._tampering, closure=callee_closure, closure_env=self._closure_env, established=self._established, depth=self._depth + 1, ) try: result = child.execute(func, args) finally: self._iterations += child._iterations return result def _eval_member(self, node: JsMemberExpression) -> Value: if isinstance(node.object, JsIdentifier) and node.object.name in STATIC_OBJECTS: raise InterpreterError obj = self._eval(node.object) if node.optional and (obj is None or obj is JS_NULL): return None key = self._member_key(node) if isinstance(obj, JsArguments): return self._read_arguments_element(node, obj, key) return self._get_property(obj, key) def _read_arguments_element( self, node: JsMemberExpression, arguments: JsArguments, key: str, ) -> Value: """ The element or the element count the arguments object answers for *key*. Every other key refuses rather than guessing, for the same reason a method read off any other value does: the answer lives on the prototype chain, on a `callee` no value domain here holds, or at an index past the end the chain may supply, and none of those is this object's to decide. """ if key == 'length': return len(arguments.values) index = canonical_array_index(key) if index is not None and 0 <= index < len(arguments.values): return arguments.values[index] raise IrreducibleExpression(node) def _eval_template(self, node: JsTemplateLiteral) -> Value: """ A template denotes the text of its runs with what its holes evaluate to between them. A run that denotes nothing makes the whole literal denote nothing: the language refuses such a template rather than reading it, so there is no string to hand back and computing one would answer for a script no engine will run. A hole's value is converted to a string, so it asks the coercion guard first: a program that installed a conversion on the object the hole evaluates to decides what the template spells, and that is a program this interpreter refuses to answer for. """ parts: list[str] = [] for i, quasi in enumerate(node.quasis): if quasi.value is None: raise IrreducibleExpression(node) parts.append(quasi.value) if i < len(node.expressions): value = self._eval(node.expressions[i]) self._require_uninterceptable(value) parts.append(to_string(value)) result = ''.join(parts) if len(result) > self.max_string_len: raise InterpreterError return result def _eval_object(self, node: JsObjectExpression) -> Value: """ Evaluate an object literal into a dict of its own data properties. A literal that installs a prototype through the plain `__proto__:` form has a member surface this dict cannot express — the installed object's properties are inherited, not owned — so it aborts interpretation rather than record `__proto__` as an ordinary key, which would invent an own property JavaScript does not create. `object_sets_prototype` decides which spellings do that; the computed form and a shorthand or method define an ordinary own property and evaluate normally. """ if object_sets_prototype(node): raise InterpreterError result: dict[str, Value] = {} for prop in node.properties: if not isinstance(prop, JsProperty): raise InterpreterError if prop.kind != JsPropertyKind.INIT: raise InterpreterError key: str if prop.computed: key = to_string(self._eval(prop.key)) elif isinstance(prop.key, JsIdentifier): key = prop.key.name elif isinstance(prop.key, JsStringLiteral): if prop.key.value is None: raise InterpreterError key = prop.key.value elif isinstance(prop.key, JsNumericLiteral): key = to_string(prop.key.value) else: raise InterpreterError result[key] = self._eval(prop.value) return result def _member_key(self, node: JsMemberExpression) -> str: if node.computed: val = self._eval(node.property) self._require_uninterceptable(val) return to_string(val) if isinstance(node.property, JsIdentifier): return node.property.name raise InterpreterError def _get_property(self, obj: Value, key: str) -> Value: """ Read property *key* off *obj*, as a property *read* rather than a call. A method name yields the method itself, which this interpreter has no value domain for — a JS function value has an observable identity, `name`, `length`, and source text — so reading one aborts interpretation instead of guessing. Crucially it must not *invoke* the method: `typeof 'abc'.charAt` is `'function'`, not the `typeof` of what `charAt()` would return. Which keys the value itself decides is `read_data_property`'s to say, so that an emulated execution and a fold read a property the same way by construction. What is left here is the half that needs this interpreter: a nullish receiver throws before any key is looked at, and a key the value does not own is `undefined` only while the prototype chain is intact. An index past the end of an array is such a key and not a value of its own. Writing `Array.prototype[5]` puts a value where the array has no slot, so a read of `a[5]` answers it, and both of the outcomes the value declines to answer therefore ask the same question: the one that names an index is not the shorter road to `undefined` it looks like. """ if obj is None or obj is JS_NULL: _js_throw('TypeError', F"Cannot read properties of {to_string(obj)} (reading '{key}')") outcome, value = read_data_property(obj, key) if outcome is MemberRead.FOUND: return value if self._property_is_absent(obj, key): return None raise InterpreterError def _property_is_absent(self, obj: Value, key: str) -> bool: """ Whether *key* provably does not exist anywhere on *obj*'s prototype chain, making a read of it `undefined`. `property_provably_absent` decides it, so that an emulated execution and a fold answer an inherited read the same way by construction; what is left here is naming the type this interpreter's value is of and handing over the effect model when there is one. Where the oracle has vouched for the anchor, the chain is asked its attributed writes alone (`property_absent_from_written_chain`) rather than refused on a reflective surface. """ if self._builtins_intact(): return property_absent_from_written_chain(self._effects, type(obj), key) return property_provably_absent(self._effects, type(obj), key) def _set_property(self, obj: Value, key: str, value: Value) -> None: """ Store *value* at *key* on *obj*, or refuse when this interpreter cannot model what the store means. An array accepts only `length` and a canonical index. Refusing every *named* property on one is not merely a gap in coverage: it is what keeps a function that writes `constructor` or `__proto__` on an array unfoldable. Those two properties decide what `slice` and its neighbours return — the read goes through ArraySpeciesCreate — so a program that sets them can make an apparently fresh array be a shared object, or make the call throw by leaving a primitive there. `EffectModel` refuses such a receiver as well, and both refusals are wanted: a widening here that started modelling named array properties would need that reasoning restated, not silently dropped. Growing the array — storing past its end, or writing a larger `length` — happens in place, filling the positions the store skipped over with holes, because the array another name holds must grow with it: `var v = u; u[87] = 5` leaves `v.length` at 88, so a copy is not a modelling of that store. How far an index or a length may reach is capped, which bounds the memory one store may ask for; a store the cap refuses still refuses here. """ if isinstance(obj, dict): if key == PROTO_KEY and key not in obj: # Writing `__proto__` runs the accessor Object.prototype carries, which installs a # prototype instead of creating an own property. An existing own `__proto__` entry # shadows that accessor, and then the write does land in the data slot. raise InterpreterError obj[key] = value return if isinstance(obj, list): if key in SEQUENCE_DATA_PROPERTIES: new_len = _to_array_length(value) if new_len > len(obj): if new_len > self.max_array_len: raise InterpreterError obj.extend([JS_HOLE] * (new_len - len(obj))) return del obj[new_len:] return index = canonical_array_index(key) if index is not None: if index >= self.max_array_len: raise InterpreterError if index > len(obj): obj.extend([JS_HOLE] * (index - len(obj))) if index == len(obj): obj.append(value) else: obj[index] = value return raise InterpreterError @staticmethod def _strict_equal(a: Value, b: Value) -> bool: return js_strict_equal(a, b) @staticmethod def _loose_equal(a: Value, b: Value) -> bool: """ Replicate the ECMA-262 abstract-equality (`==`) algorithm. `null` and `undefined` are equal to each other and to nothing else; a boolean coerces to a number; two objects compare by reference, while an object compared with a primitive coerces to a primitive first; a number compared with a string compares by numeric value. """ a_nullish = a is None or a is JS_NULL b_nullish = b is None or b is JS_NULL if a_nullish or b_nullish: return a_nullish and b_nullish if isinstance(a, bool): a = 1 if a else 0 if isinstance(b, bool): b = 1 if b else 0 a_object = isinstance(a, _OBJECT_VALUE_TYPES) b_object = isinstance(b, _OBJECT_VALUE_TYPES) if a_object and b_object: return js_strict_equal(a, b) if a_object: a = _to_primitive(a) elif b_object: b = _to_primitive(b) if isinstance(a, str) and isinstance(b, str): return a == b a_num = isinstance(a, (int, float)) b_num = isinstance(b, (int, float)) if a_num and b_num: return a == b if (a_num and isinstance(b, str)) or (isinstance(a, str) and b_num): return to_number(a) == to_number(b) return js_strict_equal(a, b) def eval_expression(self, expr) -> Value: return self._eval(expr)Methods
def execute(self, func, arguments)-
The value a call to func with arguments answers, which is the value its body returned only where a call answers that value at all. An
asyncfunction answers a promise and a generator answers a generator object whose body has not run, sowraps_returnends the interpretation rather than reporting a value the call never had.InterpreterErroris what ends it and notIrreducibleExpression: the latter hands the caller the body's return expression to splice into the call site, which is the same wrong answer written a second way.A body declaring a function anywhere but among its own statements ends it too. This interpreter has one environment per call and none per block, so it holds such a function from the entry of the body, where the language holds it in the block it is written in and, in sloppy code, in the enclosing scope only from the point the declaration runs. A read before that point is the difference, and it is one this answers with the function where a program answers
undefined.Expand source code Browse git
def execute( self, func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression, arguments: list[Value], ) -> Value: """ The value a call to *func* with *arguments* answers, which is the value its body returned only where a call answers that value at all. An `async` function answers a promise and a generator answers a generator object whose body has not run, so `wraps_return` ends the interpretation rather than reporting a value the call never had. `InterpreterError` is what ends it and not `IrreducibleExpression`: the latter hands the caller the body's return expression to splice into the call site, which is the same wrong answer written a second way. A body declaring a function anywhere but among its own statements ends it too. This interpreter has one environment per call and none per block, so it holds such a function from the entry of the body, where the language holds it in the block it is written in and, in sloppy code, in the enclosing scope only from the point the declaration runs. A read before that point is the difference, and it is one this answers with the function where a program answers `undefined`. """ if wraps_return(func): raise InterpreterError if _declares_a_function_inside_a_block(func): raise InterpreterError params = func.params param_names: list[str] = [] for p in params: if not isinstance(p, JsIdentifier): raise InterpreterError param_names.append(p.name) self._env = {} for i, name in enumerate(param_names): self._env[name] = arguments[i] if i < len(arguments) else None body = func.body for name in self._collect_hoisted_var_names(body): if name == 'arguments' and not isinstance(func, JsArrowFunctionExpression): # The call has already bound `arguments` to the argument object before any # statement runs, so an entry holding `undefined` would answer a read with a # value the call never made — including under a `var` that displaces it, where # leaving the name unbound refuses instead. continue self._env.setdefault(name, None) arguments_object = self._arguments_object(func, arguments) if arguments_object is not None: self._env['arguments'] = arguments_object for name, value in self._closure.items(): if name not in self._env: self._env[name] = _deep_copy_value(value) self._iterations = 0 if isinstance(body, JsBlockStatement): try: self._exec_statements(body.body) except _ReturnSignal as r: return r.value except _ReturnIrreducible as r: raise IrreducibleExpression(r.node) except IrreducibleExpression: raise InterpreterError except _ThrowSignal: if self._depth == 0: raise InterpreterError raise return None if body is not None: try: return self._eval(body) except IrreducibleExpression: raise IrreducibleExpression(body) except _ThrowSignal: if self._depth == 0: raise InterpreterError raise return None def eval_expression(self, expr)-
Expand source code Browse git
def eval_expression(self, expr) -> Value: return self._eval(expr)