Skip to main content

js_deobfuscator/transform/
dead.rs

1//! Dead code elimination: remove provably-unused declarations.
2//!
3//! `function f() { var x = 1; var y = 2; return y; }` → `function f() { var y = 2; return y; }`
4//!
5//! ## Safety contract
6//!
7//! A universal deobfuscator must produce code that is **still runnable** like the
8//! original. The original may invoke a binding via mechanisms invisible to static
9//! analysis: `eval`, `new Function`, `with`, computed property access on the global
10//! object, runtime dispatch tables (`__dispatch__[idx]`), reflection, etc. We can
11//! never *prove* a binding is unreachable.
12//!
13//! Therefore this pass is intentionally conservative:
14//!
15//! 1. **Function declarations are NEVER removed.** A 0-reference function may still
16//!    be called via dispatch table or computed lookup. Removing one whole-program
17//!    function can drop entire subtrees of nested helpers — this exact bug was
18//!    observed wiping 7700 lines from kasada decompiled output.
19//! 2. **Module/program-scope `var/let/const` are NEVER removed**, because:
20//!    - `var` at module scope can still be observable via host hooks or stale
21//!      tooling that opts out of strict module semantics;
22//!    - the cost of being wrong (broken script) outweighs the benefit (slightly
23//!      shorter file).
24//! 3. **Function-/block-scope variable declarations** with 0 read references and a
25//!    side-effect-free initializer ARE removed — within a function body the
26//!    surface area is bounded by the parent function and OXC's symbol table is
27//!    authoritative there.
28//! 4. **Empty statements** are always removed.
29//!
30//! Vendor-specific aggressive DCE belongs in a target/locked module, not here.
31
32use oxc::allocator::Allocator;
33use oxc::ast::ast::{Program, Statement};
34use oxc::semantic::{Scoping, SymbolId};
35
36use oxc_traverse::{Traverse, TraverseCtx, traverse_mut};
37
38use crate::ast::query;
39use crate::engine::error::Result;
40use crate::engine::module::{Module, TransformResult};
41use crate::scope::{query as scope_query, resolve};
42
43/// Dead code elimination module. See module docs for the safety contract.
44pub struct DeadCodeEliminator;
45
46impl Module for DeadCodeEliminator {
47    fn name(&self) -> &'static str {
48        "DeadCodeEliminator"
49    }
50
51    fn changes_symbols(&self) -> bool {
52        true
53    }
54
55    fn transform<'a>(
56        &mut self,
57        allocator: &'a Allocator,
58        program: &mut Program<'a>,
59        scoping: Scoping,
60    ) -> Result<TransformResult> {
61        let mut visitor = DeadCodeVisitor { modifications: 0 };
62        let scoping = traverse_mut(&mut visitor, allocator, program, scoping, ());
63        Ok(TransformResult { modifications: visitor.modifications, scoping })
64    }
65}
66
67struct DeadCodeVisitor {
68    modifications: usize,
69}
70
71impl<'a> Traverse<'a, ()> for DeadCodeVisitor {
72    fn exit_statements(
73        &mut self,
74        stmts: &mut oxc::allocator::Vec<'a, Statement<'a>>,
75        ctx: &mut TraverseCtx<'a, ()>,
76    ) {
77        let allocator = ctx.ast.allocator;
78        let mut new_stmts = ctx.ast.vec();
79        let mut removed = 0;
80
81        for stmt in stmts.drain(..) {
82            if should_remove(&stmt, ctx.scoping()) {
83                removed += 1;
84                continue;
85            }
86            new_stmts.push(stmt);
87        }
88
89        *stmts = new_stmts;
90        let _ = allocator;
91        self.modifications += removed;
92    }
93}
94
95/// True if the symbol is declared at the program (root) scope.
96#[inline]
97fn is_module_scope(scoping: &Scoping, symbol_id: SymbolId) -> bool {
98    scoping.symbol_scope_id(symbol_id) == scoping.root_scope_id()
99}
100
101/// Check if a statement should be removed.
102///
103/// See the module docs for the safety contract this enforces.
104fn should_remove(stmt: &Statement, scoping: &Scoping) -> bool {
105    match stmt {
106        Statement::VariableDeclaration(decl) => {
107            // All declarators must be eligible: declared at non-root scope,
108            // 0 read references, side-effect-free init.
109            decl.declarations.iter().all(|d| {
110                let Some(symbol_id) = resolve::get_declarator_symbol(d) else {
111                    return false;
112                };
113                // Safety: never remove module/program-scope declarations.
114                if is_module_scope(scoping, symbol_id) {
115                    return false;
116                }
117                if scope_query::has_reads(scoping, symbol_id) {
118                    return false;
119                }
120                d.init.as_ref().is_none_or(|init| query::is_side_effect_free(init))
121            })
122        }
123        // Function declarations are intentionally NEVER removed — see module docs.
124        // The 0-reference check is unsound in the presence of dispatch tables,
125        // eval, computed lookups, reflection, etc. Universal deobfuscation must
126        // preserve callable definitions; vendor-specific pruning belongs in a
127        // locked module.
128        Statement::FunctionDeclaration(_) => false,
129        Statement::EmptyStatement(_) => true,
130        _ => false,
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use oxc::codegen::Codegen;
138    use oxc::parser::Parser;
139    use oxc::semantic::SemanticBuilder;
140    use oxc::span::SourceType;
141
142    fn eliminate(source: &str) -> (String, usize) {
143        let allocator = Allocator::default();
144        let mut program = Parser::new(&allocator, source, SourceType::mjs())
145            .parse()
146            .program;
147        let scoping = SemanticBuilder::new().build(&program).semantic.into_scoping();
148
149        let mut module = DeadCodeEliminator;
150        let result = module.transform(&allocator, &mut program, scoping).unwrap();
151        (Codegen::new().build(&program).code, result.modifications)
152    }
153
154    // ── Module-scope safety: nothing at the program root must be removed ──
155
156    #[test]
157    fn test_keep_module_scope_unused_var() {
158        // Module-scope `var` is never removed even when statically unread —
159        // it may be observable via host hooks or runtime reflection.
160        let (code, mods) = eliminate("var x = 1; var y = 2; console.log(y);");
161        assert_eq!(mods, 0, "module-scope vars must be preserved");
162        assert!(code.contains("var x"), "should keep module-scope x: {code}");
163        assert!(code.contains("var y"), "should keep module-scope y: {code}");
164    }
165
166    #[test]
167    fn test_keep_unused_function_declaration() {
168        // The kasada bug: 552 dispatch-called functions had 0 static refs.
169        // Removing them dropped 7700 lines of executable code. Never again.
170        let (code, mods) = eliminate("function unused() {} console.log(1);");
171        assert_eq!(mods, 0, "function declarations must never be removed");
172        assert!(code.contains("function unused"), "must preserve unused: {code}");
173    }
174
175    #[test]
176    fn test_keep_used_function() {
177        let (code, _) = eliminate("function used() {} used();");
178        assert!(code.contains("function used"), "should keep used function: {code}");
179    }
180
181    #[test]
182    fn test_keep_side_effectful_module_var() {
183        let (code, mods) = eliminate("var x = foo(); console.log(1);");
184        assert_eq!(mods, 0, "should not remove side-effectful init");
185        assert!(code.contains("foo()"), "got: {code}");
186    }
187
188    // ── Function-scope DCE: bounded surface, safe to prune ──
189
190    #[test]
191    fn test_remove_function_scope_unused_var() {
192        let (code, mods) = eliminate(
193            "function f() { var x = 1; var y = 2; return y; }"
194        );
195        assert!(mods > 0, "function-scope unused var should be removed");
196        assert!(!code.contains("var x"), "should remove function-scope x: {code}");
197        assert!(code.contains("var y"), "should keep used y: {code}");
198    }
199
200    #[test]
201    fn test_keep_function_scope_side_effect_var() {
202        let (code, mods) = eliminate(
203            "function f() { var x = foo(); return 1; }"
204        );
205        assert_eq!(mods, 0, "side-effectful init must be preserved");
206        assert!(code.contains("foo()"), "got: {code}");
207    }
208
209    #[test]
210    fn test_keep_nested_function_declaration() {
211        // Even nested, function declarations are preserved — they may be
212        // referenced through dispatch.
213        let (code, mods) = eliminate(
214            "function outer() { function helper() {} return 1; }"
215        );
216        assert_eq!(mods, 0, "nested function declarations must be preserved");
217        assert!(code.contains("function helper"), "got: {code}");
218    }
219
220    #[test]
221    fn test_remove_empty_statement() {
222        let (code, mods) = eliminate("function f() { ;;; return 1; }");
223        assert!(mods > 0, "empty statements always removable");
224        assert!(!code.contains(";;"), "got: {code}");
225    }
226}