Skip to content

Commit 99007f0

Browse files
vasie1337claude
andcommitted
unflatten-string-order: recover the hoisted-init while-form
The pass recognized only `for (a = <arr>, i = 0; true;) switch (a[i++])`. The same dispatcher also appears as `var a = <arr>, i = 0; while (true) { switch (a[i++]); break; }` — the cursor setup hoisted into statement(s) before the loop. The obfuscator emits this directly, and it is what remains after a `for(index)` wrapper around the dispatcher is lowered (and after const-object-inline materializes the order array from a string-map property). Generalize `try_recover` to scan for both forms and splice a statement *range* (the while-form also consumes its preceding init). The init parsing, the `switch(a[i++])` body match, the immutability/no-default/dispatch-clean gates, and the cursor-escape census are now shared via `finish`; the escape gate simply spans the init statement(s) plus the loop. Accepts one `var a = …, i = 0;` or two adjacent single-binding statements, required to sit directly before the loop so the spliced range is contiguous. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cbafb6f commit 99007f0

3 files changed

Lines changed: 282 additions & 100 deletions

File tree

src/passes/unflatten_string_order.rs

Lines changed: 198 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -140,90 +140,132 @@ impl<'a> Walker<'a> {
140140
}
141141

142142
fn try_recover(&mut self, stmts: &mut ArenaVec<'a, Statement<'a>>) {
143-
let mut hit: Option<(usize, Vec<Statement<'a>>)> = None;
143+
// Each hit is the inclusive statement range `[start, end]` to replace with
144+
// the linearized `seq`. The for-form is a single statement (`start == end`);
145+
// the while-form also consumes its preceding hoisted-init statement(s).
146+
let mut hit: Option<(usize, usize, Vec<Statement<'a>>)> = None;
144147
for i in 0..stmts.len() {
145-
let Statement::ForStatement(f) = &stmts[i] else {
146-
continue;
147-
};
148-
// The loop must never exit via its own test: `for (…; true; )` or
149-
// `for (…; ; )`. A loop with a real test could fall through before the
150-
// chain ends — not a static linear walk.
151-
let test_is_always = match &f.test {
152-
None => true,
153-
Some(Expression::BooleanLiteral(b)) => b.value,
154-
_ => false,
155-
};
156-
if !test_is_always {
157-
continue;
158-
}
159-
// No `for`-update clause: the cursor is advanced solely by `i++` inside
160-
// the discriminant. An update would be a second, unmodeled mutation.
161-
if f.update.is_some() {
162-
continue;
163-
}
164-
let Some(init) = &f.init else { continue };
165-
let Some((arr_name, idx_name, keys)) = parse_init(init) else {
166-
continue;
167-
};
168-
// The order array must be provably immutable (never `push`/`[k]=`/etc.).
169-
if self.mutable.contains(&arr_name) {
170-
continue;
148+
if let Some(seq) = self.try_for_form(&stmts[i]) {
149+
hit = Some((i, i, seq));
150+
break;
171151
}
172-
// Body: `switch (a[i++]) { … }`, optionally wrapped in a block and/or
173-
// trailed by a `break`. `has_break_exit` is true when the loop body has a
174-
// trailing `break` reachable when the switch matches nothing — i.e. the
175-
// loop cleanly exits once the cursor runs off the end of the array.
176-
let Some((switch, has_break_exit)) = body_switch(&f.body, &arr_name, &idx_name) else {
177-
continue;
178-
};
179-
if switch.cases.iter().any(|c| c.test.is_none()) {
180-
continue; // no `default` clause
181-
}
182-
let Some(cases) = parse_cases(switch, &arr_name, &idx_name) else {
183-
continue;
184-
};
185-
// `a` and `i` must be dispatch-only: referenced *nowhere* outside this
186-
// `for` statement, program-wide. Compare each name's total occurrence
187-
// count to its count inside the for-statement; equal ⇒ no escape. (The
188-
// census counts binding ids too, so the param/var declaration of `a`/`i`
189-
// outside the loop also counts as an escape and is correctly rejected —
190-
// unless, as in the CFF shape, the name's only binding is the for-init
191-
// assignment target, which lives inside the for-statement.)
192-
let for_stmt = &stmts[i];
193-
let arr_local = count_name_in_stmt(for_stmt, &arr_name);
194-
let idx_local = count_name_in_stmt(for_stmt, &idx_name);
195-
if self.global_counts.get(&arr_name).copied().unwrap_or(0) != arr_local
196-
|| self.global_counts.get(&idx_name).copied().unwrap_or(0) != idx_local
197-
{
198-
continue;
152+
if let Some((start, seq)) = self.try_while_form(stmts, i) {
153+
hit = Some((start, i, seq));
154+
break;
199155
}
200-
let Some(seq) = simulate_sequence(&keys, &cases, has_break_exit, self.ast.allocator)
201-
else {
202-
continue;
203-
};
204-
hit = Some((i, seq));
205-
break;
206156
}
207157

208-
let Some((i, seq)) = hit else {
158+
let Some((start, end, seq)) = hit else {
209159
return;
210160
};
211-
// Splice: replace the `for` dispatcher (statement i) with the linear
161+
// Splice: replace the dispatcher range `[start, end]` with the linear
212162
// sequence. Everything else stays in order.
213163
let old = std::mem::replace(stmts, self.ast.vec());
214164
let mut seq = Some(seq);
215165
for (k, s) in old.into_iter().enumerate() {
216-
if k == i {
166+
if k == start {
217167
for st in seq.take().unwrap() {
218168
stmts.push(st);
219169
}
220-
continue;
170+
}
171+
if (start..=end).contains(&k) {
172+
continue; // drop the originals in the dispatcher range
221173
}
222174
stmts.push(s);
223175
}
224176
self.changed = true;
225177
}
226178

179+
/// `for (a = <const arr>, i = 0; true; ) switch (a[i++]) { … }` — the cursor
180+
/// setup lives in the for-init. Returns the linearized sequence, or `None`.
181+
fn try_for_form(&self, stmt: &Statement<'a>) -> Option<Vec<Statement<'a>>> {
182+
let Statement::ForStatement(f) = stmt else {
183+
return None;
184+
};
185+
// The loop must never exit via its own test: `for (…; true; )` /
186+
// `for (…; ; )`. A real test could fall through before the chain ends.
187+
let test_is_always = match &f.test {
188+
None => true,
189+
Some(Expression::BooleanLiteral(b)) => b.value,
190+
_ => false,
191+
};
192+
if !test_is_always {
193+
return None;
194+
}
195+
// No `for`-update clause: the cursor advances solely via `i++` in the
196+
// discriminant. An update would be a second, unmodeled mutation.
197+
if f.update.is_some() {
198+
return None;
199+
}
200+
let (arr_name, idx_name, keys) = parse_init(f.init.as_ref()?)?;
201+
let (switch, has_break_exit) = body_switch(&f.body, &arr_name, &idx_name)?;
202+
// Escape gate over the single `for` statement (see `finish`).
203+
self.finish(&arr_name, &idx_name, &keys, switch, has_break_exit, &[stmt])
204+
}
205+
206+
/// `var a = <const arr>, i = 0; while (true) { switch (a[i++]) { … } [break;] }`
207+
/// — the equivalent dispatcher whose cursor setup is hoisted to statement(s)
208+
/// preceding the loop (the obfuscator emits this, and lowering a `for(index)`
209+
/// wrapper leaves it). Returns `(start_index, sequence)`: the index of the
210+
/// first init statement consumed, and the linearized body.
211+
fn try_while_form(
212+
&self,
213+
stmts: &[Statement<'a>],
214+
i: usize,
215+
) -> Option<(usize, Vec<Statement<'a>>)> {
216+
let Statement::WhileStatement(w) = &stmts[i] else {
217+
return None;
218+
};
219+
// `while (true)` / `while (1)` — never exits via the test.
220+
let always = matches!(&w.test, Expression::BooleanLiteral(b) if b.value)
221+
|| int_literal(&w.test).is_some_and(|n| n != 0);
222+
if !always {
223+
return None;
224+
}
225+
let (start, pairs) = gather_hoisted_init(stmts, i)?;
226+
let (arr_name, idx_name, keys) = pairs_to_init(pairs)?;
227+
let (switch, has_break_exit) = body_switch(&w.body, &arr_name, &idx_name)?;
228+
// Escape gate spans the init statement(s) plus the loop — `a`/`i` must be
229+
// referenced nowhere else (see `finish`). The init's binding ids are not
230+
// counted (the census skips declarations), so a clean dispatcher's only
231+
// counted references are the single `a[i++]` read and `i++`.
232+
let escape: Vec<&Statement<'a>> = stmts[start..=i].iter().collect();
233+
let seq = self.finish(&arr_name, &idx_name, &keys, switch, has_break_exit, &escape)?;
234+
Some((start, seq))
235+
}
236+
237+
/// Shared gating + linearization for both dispatcher forms. `escape_stmts` is
238+
/// the set of statements the cursor `a`/`i` is allowed to appear in; if either
239+
/// name's program-wide reference count exceeds its count there, the cursor
240+
/// escapes and we decline.
241+
fn finish(
242+
&self,
243+
arr_name: &str,
244+
idx_name: &str,
245+
keys: &[Key],
246+
switch: &SwitchStatement<'a>,
247+
has_break_exit: bool,
248+
escape_stmts: &[&Statement<'a>],
249+
) -> Option<Vec<Statement<'a>>> {
250+
// The order array must be provably immutable (never `push`/`[k]=`/etc.).
251+
if self.mutable.contains(arr_name) {
252+
return None;
253+
}
254+
if switch.cases.iter().any(|c| c.test.is_none()) {
255+
return None; // no `default` clause
256+
}
257+
let cases = parse_cases(switch, arr_name, idx_name)?;
258+
let count_in = |name: &str| -> usize {
259+
escape_stmts.iter().map(|s| count_name_in_stmt(s, name)).sum()
260+
};
261+
if self.global_counts.get(arr_name).copied().unwrap_or(0) != count_in(arr_name)
262+
|| self.global_counts.get(idx_name).copied().unwrap_or(0) != count_in(idx_name)
263+
{
264+
return None;
265+
}
266+
simulate_sequence(keys, &cases, has_break_exit, self.ast.allocator)
267+
}
268+
227269
/// Descend into every statement list reachable from `stmt`, so nested
228270
/// dispatchers (inside functions, `try` blocks, branches, …) are recovered.
229271
/// Mirrors `unflatten_cfg`'s recursion.
@@ -327,45 +369,17 @@ enum Key {
327369
fn parse_init(init: &ForStatementInit<'_>) -> Option<(String, String, Vec<Key>)> {
328370
// Collect the (target-name, init-expr) pairs in source order.
329371
let pairs: Vec<(String, &Expression<'_>)> = match init {
330-
ForStatementInit::VariableDeclaration(vd) => {
331-
let mut out = Vec::with_capacity(vd.declarations.len());
332-
for d in &vd.declarations {
333-
let BindingPattern::BindingIdentifier(id) = &d.id else {
334-
return None;
335-
};
336-
out.push((id.name.to_string(), d.init.as_ref()?));
337-
}
338-
out
339-
}
372+
ForStatementInit::VariableDeclaration(vd) => decl_pairs(vd)?,
340373
// An expression init: a single assignment or a comma sequence of them.
341-
_ => {
342-
let expr = init.as_expression()?;
343-
let mut exprs: Vec<&Expression<'_>> = Vec::new();
344-
match expr {
345-
Expression::SequenceExpression(seq) => {
346-
for e in &seq.expressions {
347-
exprs.push(e);
348-
}
349-
}
350-
other => exprs.push(other),
351-
}
352-
let mut out = Vec::with_capacity(exprs.len());
353-
for e in exprs {
354-
let Expression::AssignmentExpression(a) = e else {
355-
return None;
356-
};
357-
if a.operator != AssignmentOperator::Assign {
358-
return None;
359-
}
360-
let AssignmentTarget::AssignmentTargetIdentifier(id) = &a.left else {
361-
return None;
362-
};
363-
out.push((id.name.to_string(), &a.right));
364-
}
365-
out
366-
}
374+
_ => assign_pairs(init.as_expression()?)?,
367375
};
376+
pairs_to_init(pairs)
377+
}
368378

379+
/// Reduce the ordered `(name, init-expr)` pairs to `(array, cursor, keys)`. The
380+
/// shared tail of every init form: exactly two bindings — a constant key array,
381+
/// then a cursor initialized to `0`.
382+
fn pairs_to_init(pairs: Vec<(String, &Expression<'_>)>) -> Option<(String, String, Vec<Key>)> {
369383
if pairs.len() != 2 {
370384
return None;
371385
}
@@ -382,6 +396,90 @@ fn parse_init(init: &ForStatementInit<'_>) -> Option<(String, String, Vec<Key>)>
382396
Some((arr_name.clone(), idx_name.clone(), keys))
383397
}
384398

399+
/// `var a = …, i = …` → its `(name, init)` pairs. Every declarator must be a
400+
/// plain identifier with an initializer.
401+
fn decl_pairs<'b, 'a>(
402+
vd: &'b oxc_ast::ast::VariableDeclaration<'a>,
403+
) -> Option<Vec<(String, &'b Expression<'a>)>> {
404+
let mut out = Vec::with_capacity(vd.declarations.len());
405+
for d in &vd.declarations {
406+
let BindingPattern::BindingIdentifier(id) = &d.id else {
407+
return None;
408+
};
409+
out.push((id.name.to_string(), d.init.as_ref()?));
410+
}
411+
Some(out)
412+
}
413+
414+
/// `a = …` or `a = …, i = …` (a comma sequence of simple assignments) → its
415+
/// `(name, init)` pairs.
416+
fn assign_pairs<'b, 'a>(expr: &'b Expression<'a>) -> Option<Vec<(String, &'b Expression<'a>)>> {
417+
let mut exprs: Vec<&Expression<'_>> = Vec::new();
418+
match expr {
419+
Expression::SequenceExpression(seq) => {
420+
for e in &seq.expressions {
421+
exprs.push(e);
422+
}
423+
}
424+
other => exprs.push(other),
425+
}
426+
let mut out = Vec::with_capacity(exprs.len());
427+
for e in exprs {
428+
let Expression::AssignmentExpression(a) = e else {
429+
return None;
430+
};
431+
if a.operator != AssignmentOperator::Assign {
432+
return None;
433+
}
434+
let AssignmentTarget::AssignmentTargetIdentifier(id) = &a.left else {
435+
return None;
436+
};
437+
out.push((id.name.to_string(), &a.right));
438+
}
439+
Some(out)
440+
}
441+
442+
/// Interpret a *statement* as part of a hoisted dispatcher init — the while-form
443+
/// `var a = …, i = 0; while (true) …` where the cursor setup precedes the loop
444+
/// instead of living in a `for`-init. Accepts a `var` declaration or an
445+
/// assignment/comma-sequence expression statement.
446+
fn stmt_pairs<'b, 'a>(stmt: &'b Statement<'a>) -> Option<Vec<(String, &'b Expression<'a>)>> {
447+
match stmt {
448+
Statement::VariableDeclaration(vd) => decl_pairs(vd),
449+
Statement::ExpressionStatement(es) => assign_pairs(&es.expression),
450+
_ => None,
451+
}
452+
}
453+
454+
/// Collect the cursor-init `(name, init)` pairs from the statement(s) immediately
455+
/// preceding the while-loop at `i`, returning the first consumed index. Accepts
456+
/// either one statement that binds both (`var a = …, i = 0;`) or two adjacent
457+
/// statements that bind one each (`var a = …; var i = 0;`). The init must sit
458+
/// directly before the loop so the spliced range stays contiguous.
459+
fn gather_hoisted_init<'b, 'a>(
460+
stmts: &'b [Statement<'a>],
461+
i: usize,
462+
) -> Option<(usize, Vec<(String, &'b Expression<'a>)>)> {
463+
if i == 0 {
464+
return None;
465+
}
466+
if let Some(pairs) = stmt_pairs(&stmts[i - 1]) {
467+
if pairs.len() == 2 {
468+
return Some((i - 1, pairs));
469+
}
470+
if pairs.len() == 1 && i >= 2 {
471+
if let Some(prev) = stmt_pairs(&stmts[i - 2]) {
472+
if prev.len() == 1 {
473+
let mut combined = prev;
474+
combined.extend(pairs);
475+
return Some((i - 2, combined));
476+
}
477+
}
478+
}
479+
}
480+
None
481+
}
482+
385483
/// Resolve a provably constant array of dispatch keys from the array initializer:
386484
/// * `"a|b|c".split("|")` (any single-char/string separator) → split the literal;
387485
/// * `["a", "b", 3]` → a literal array of string/number elements.

tests/equivalence.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,6 +1804,26 @@ fn string_order_chain_preserves_value_and_order() {
18041804
);
18051805
}
18061806

1807+
#[test]
1808+
fn string_order_while_form_preserves_value_and_order() {
1809+
// The hoisted-init while-form: cursor setup precedes `while (true)`. Same
1810+
// array-driven order must hold.
1811+
assert_equivalent(
1812+
"var log = ''; \
1813+
(function () { \
1814+
var a = \"2|0|1\".split(\"|\"), i = 0; \
1815+
while (true) { \
1816+
switch (a[i++]) { \
1817+
case \"0\": log += 'b'; continue; \
1818+
case \"1\": log += 'c'; continue; \
1819+
case \"2\": log += 'a'; continue; \
1820+
} \
1821+
break; \
1822+
} \
1823+
})(); __r = log;",
1824+
);
1825+
}
1826+
18071827
#[test]
18081828
fn string_order_return_terminal_preserves_value() {
18091829
// A `return` case stops the walk mid-array; later keys must not run.

0 commit comments

Comments
 (0)