Skip to main content

js_deobfuscator/engine/
api.rs

1//! Public API — JSDeobfuscator builder.
2
3use oxc::allocator::Allocator;
4use oxc::codegen::Codegen;
5use oxc::parser::Parser;
6use oxc::span::SourceType;
7
8use super::config::Config;
9use super::error::{DeobError, Result};
10use super::module::Module;
11use super::pipeline::{Engine, EngineResult};
12use crate::targets::Target;
13
14/// Result of a deobfuscation run.
15pub struct DeobfuscateResult {
16    /// The deobfuscated source code.
17    pub code: String,
18    /// Number of convergence iterations.
19    pub iterations: usize,
20    /// Total AST modifications made.
21    pub modifications: usize,
22    /// Whether the engine reached a fixed point.
23    pub converged: bool,
24}
25
26/// High-level deobfuscator with builder API.
27///
28/// ```ignore
29/// use js_deobfuscator::JSDeobfuscator;
30///
31/// let result = JSDeobfuscator::new()
32///     .deobfuscate("var a = 1 + 2;")?;
33/// assert!(result.code.contains("var a = 3"));
34/// ```
35pub struct JSDeobfuscator {
36    config: Config,
37    target: Option<Target>,
38    custom_common: Vec<Box<dyn Module>>,
39    custom_locked: Vec<Box<dyn Module>>,
40}
41
42impl Default for JSDeobfuscator {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl JSDeobfuscator {
49    /// Create a deobfuscator with default configuration.
50    #[must_use]
51    pub fn new() -> Self {
52        Self {
53            config: Config::default(),
54            target: None,
55            custom_common: Vec::new(),
56            custom_locked: Vec::new(),
57        }
58    }
59
60    /// Create a deobfuscator with the given configuration.
61    #[must_use]
62    pub fn with_config(config: Config) -> Self {
63        Self {
64            config,
65            target: None,
66            custom_common: Vec::new(),
67            custom_locked: Vec::new(),
68        }
69    }
70
71    /// Set the obfuscation target (enables target-specific transforms).
72    ///
73    /// ```ignore
74    /// use js_deobfuscator::{JSDeobfuscator, Target};
75    ///
76    /// let result = JSDeobfuscator::new()
77    ///     .target(Target::ObfuscatorIO)
78    ///     .deobfuscate(source)?;
79    /// ```
80    #[must_use]
81    pub fn target(mut self, target: Target) -> Self {
82        self.target = Some(target);
83        self
84    }
85
86    /// Set maximum convergence iterations.
87    #[must_use]
88    pub fn max_iterations(mut self, n: usize) -> Self {
89        self.config.max_iterations = n;
90        self
91    }
92
93    /// Enable or disable static folding.
94    #[must_use]
95    pub fn static_eval(mut self, enabled: bool) -> Self {
96        self.config.static_eval = enabled;
97        self
98    }
99
100    /// Enable or disable dynamic evaluation.
101    #[must_use]
102    pub fn dynamic_eval(mut self, enabled: bool) -> Self {
103        self.config.dynamic_eval = enabled;
104        self
105    }
106
107    /// Enable or disable semantic transforms.
108    #[must_use]
109    pub fn transforms(mut self, enabled: bool) -> Self {
110        self.config.transforms = enabled;
111        self
112    }
113
114    /// Add a global context value.
115    #[must_use]
116    pub fn global(mut self, name: &str, value: serde_json::Value) -> Self {
117        self.config.globals.insert(name.to_string(), value);
118        self
119    }
120
121    /// Add a custom common module (runs in convergence loop).
122    #[must_use]
123    pub fn add_common(mut self, module: Box<dyn Module>) -> Self {
124        self.custom_common.push(module);
125        self
126    }
127
128    /// Add a custom locked module (runs once after common stabilizes).
129    #[must_use]
130    pub fn add_locked(mut self, module: Box<dyn Module>) -> Self {
131        self.custom_locked.push(module);
132        self
133    }
134
135    /// Deobfuscate JavaScript source code.
136    pub fn deobfuscate(self, source: &str) -> Result<DeobfuscateResult> {
137        let allocator = Allocator::default();
138        let ret = Parser::new(&allocator, source, SourceType::mjs()).parse();
139
140        if !ret.errors.is_empty() {
141            let msg = ret.errors.iter()
142                .map(|e| e.to_string())
143                .collect::<Vec<_>>()
144                .join("; ");
145            return Err(DeobError::Parse(msg));
146        }
147
148        let mut program = ret.program;
149
150        // Build module lists
151        let mut common: Vec<Box<dyn Module>> = Vec::new();
152        let mut locked: Vec<Box<dyn Module>> = Vec::new();
153
154        // Built-in modules — order matters (see ARCHITECTURE.md)
155        // inject → resolve → propagate → inline → fold → simplify → eliminate
156        if self.config.transforms {
157            if !self.config.globals.is_empty() {
158                common.push(Box::new(crate::transform::global::GlobalResolver::new(
159                    self.config.globals.clone(),
160                )));
161            }
162            common.push(Box::new(crate::transform::object::ObjectPropResolver));
163            common.push(Box::new(crate::transform::constant::ConstantPropagator));
164            common.push(Box::new(crate::transform::alias::AliasInliner));
165            common.push(Box::new(crate::transform::proxy::ProxyInliner));
166        }
167        if self.config.static_eval {
168            common.push(Box::new(crate::fold::StaticFolder));
169        }
170        if self.config.transforms {
171            common.push(Box::new(crate::transform::member::MemberSimplifier));
172            common.push(Box::new(crate::transform::dead::DeadCodeEliminator));
173        }
174
175        // Target-specific locked modules
176        if let Some(target) = self.target {
177            locked.extend(target.modules());
178        }
179
180        // Custom modules
181        common.extend(self.custom_common);
182        locked.extend(self.custom_locked);
183
184        // Run engine
185        let mut engine = Engine::new(common, locked, self.config.max_iterations);
186        let EngineResult {
187            iterations,
188            total_modifications,
189            converged,
190            ..
191        } = engine.run(&allocator, &mut program)?;
192
193        // Codegen
194        let code = Codegen::new().build(&program).code;
195
196        Ok(DeobfuscateResult {
197            code,
198            iterations,
199            modifications: total_modifications,
200            converged,
201        })
202    }
203}
204
205// ============================================================================
206// Tests
207// ============================================================================
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_simple_fold() {
215        let result = JSDeobfuscator::new()
216            .deobfuscate("console.log(1 + 2);")
217            .unwrap();
218        assert!(result.code.contains("console.log(3)"), "got: {}", result.code);
219        assert!(result.converged);
220    }
221
222    #[test]
223    fn test_string_concat() {
224        let result = JSDeobfuscator::new()
225            .deobfuscate("console.log(\"hello\" + \" world\");")
226            .unwrap();
227        assert!(result.code.contains("\"hello world\""), "got: {}", result.code);
228    }
229
230    #[test]
231    fn test_math_fold() {
232        let result = JSDeobfuscator::new()
233            .deobfuscate("console.log(Math.floor(1.7));")
234            .unwrap();
235        assert!(result.code.contains("console.log(1)"), "got: {}", result.code);
236    }
237
238    #[test]
239    fn test_atob_fold() {
240        let result = JSDeobfuscator::new()
241            .deobfuscate("console.log(atob(\"SGVsbG8=\"));")
242            .unwrap();
243        assert!(result.code.contains("\"Hello\""), "got: {}", result.code);
244    }
245
246    #[test]
247    fn test_ternary_fold() {
248        let result = JSDeobfuscator::new()
249            .deobfuscate("console.log(true ? 42 : 0);")
250            .unwrap();
251        assert!(result.code.contains("console.log(42)"), "got: {}", result.code);
252    }
253
254    #[test]
255    fn test_dead_code_removal() {
256        let result = JSDeobfuscator::new()
257            .deobfuscate("if (false) { var dead = 1; } console.log(2);")
258            .unwrap();
259        assert!(!result.code.contains("dead"), "got: {}", result.code);
260        assert!(result.code.contains("console.log(2)"), "got: {}", result.code);
261    }
262
263    #[test]
264    fn test_chained_fold() {
265        let result = JSDeobfuscator::new()
266            .deobfuscate("console.log(String(1 + 2));")
267            .unwrap();
268        assert!(result.code.contains("\"3\""), "got: {}", result.code);
269    }
270
271    #[test]
272    fn test_complex_chain() {
273        let result = JSDeobfuscator::new()
274            .deobfuscate("console.log(atob(\"dGVzdA==\").toUpperCase());")
275            .unwrap();
276        assert!(result.code.contains("\"TEST\""), "got: {}", result.code);
277    }
278
279    #[test]
280    fn test_convergence_with_nested() {
281        let result = JSDeobfuscator::new()
282            .deobfuscate("console.log(parseInt(\"0xff\") + 1);")
283            .unwrap();
284        assert!(result.code.contains("256"), "got: {}", result.code);
285    }
286
287    #[test]
288    fn test_parse_error() {
289        let result = JSDeobfuscator::new()
290            .deobfuscate("var x = ;");
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_no_modifications_converges() {
296        let result = JSDeobfuscator::new()
297            .deobfuscate("console.log(y);")
298            .unwrap();
299        assert!(result.converged);
300    }
301
302    #[test]
303    fn test_disabled_static_eval() {
304        let result = JSDeobfuscator::new()
305            .static_eval(false)
306            .transforms(false)
307            .deobfuscate("var x = 1 + 2;")
308            .unwrap();
309        assert!(result.code.contains("1 + 2"), "got: {}", result.code);
310    }
311
312    #[test]
313    fn test_constant_propagation() {
314        let result = JSDeobfuscator::new()
315            .deobfuscate("var key = 42; console.log(key);")
316            .unwrap();
317        // constant propagation inlines key → 42
318        assert!(result.code.contains("console.log(42)"), "got: {}", result.code);
319    }
320
321    #[test]
322    fn test_dead_var_removed_function_scope() {
323        // DCE only operates at function scope or smaller — module-scope vars
324        // are preserved for safety (see transform/dead.rs module docs).
325        let result = JSDeobfuscator::new()
326            .deobfuscate("function f() { var unused = 1; console.log(2); } f();")
327            .unwrap();
328        assert!(!result.code.contains("unused"), "got: {}", result.code);
329        assert!(result.code.contains("console.log(2)"), "got: {}", result.code);
330    }
331
332    #[test]
333    fn test_module_scope_var_preserved() {
334        // Safety: module-scope `var` must NOT be removed even if statically
335        // unread. It may be observable via host hooks, eval, or reflection.
336        let result = JSDeobfuscator::new()
337            .deobfuscate("var unused = 1; console.log(2);")
338            .unwrap();
339        assert!(result.code.contains("unused"), "module-scope var must be preserved: {}", result.code);
340    }
341
342    #[test]
343    fn test_member_simplification() {
344        let result = JSDeobfuscator::new()
345            .deobfuscate("console.log(obj[\"property\"]);")
346            .unwrap();
347        assert!(result.code.contains("obj.property"), "got: {}", result.code);
348    }
349
350    #[test]
351    fn test_full_pipeline() {
352        // Simulates obfuscated pattern: const + fold + dead code.
353        // Wrapped in a function so DCE is allowed to prune _unused
354        // (module-scope vars are preserved for safety — see dead.rs).
355        let result = JSDeobfuscator::new()
356            .deobfuscate(r#"
357                function main() {
358                    var _0x1 = "Hello";
359                    var _0x2 = " ";
360                    var _0x3 = "World";
361                    var _unused = 999;
362                    console.log(_0x1 + _0x2 + _0x3);
363                }
364                main();
365            "#)
366            .unwrap();
367        assert!(result.code.contains("\"Hello World\""), "got: {}", result.code);
368        assert!(!result.code.contains("_unused"), "got: {}", result.code);
369        assert!(!result.code.contains("999"), "got: {}", result.code);
370    }
371
372    #[test]
373    fn test_alias_inlining() {
374        let result = JSDeobfuscator::new()
375            .deobfuscate("var e = Yp; console.log(e(445));")
376            .unwrap();
377        assert!(result.code.contains("Yp(445)"), "alias not inlined: {}", result.code);
378    }
379
380    #[test]
381    fn test_object_prop_resolution() {
382        let result = JSDeobfuscator::new()
383            .deobfuscate("var t = {F: 445, H: 417}; console.log(t.F + t.H);")
384            .unwrap();
385        assert!(result.code.contains("862"), "got: {}", result.code);
386    }
387
388    #[test]
389    fn test_global_injection() {
390        let result = JSDeobfuscator::new()
391            .global("window", serde_json::json!({"secret": "abc123"}))
392            .deobfuscate("console.log(window.secret);")
393            .unwrap();
394        assert!(result.code.contains("\"abc123\""), "got: {}", result.code);
395    }
396
397    #[test]
398    fn test_obfuscated_pattern_full() {
399        // Realistic obfuscation: lookup table + alias + bracket notation + dead code.
400        // Wrapped in a function so DCE is allowed to prune _unused
401        // (module-scope vars are preserved for safety — see dead.rs).
402        let result = JSDeobfuscator::new()
403            .deobfuscate(r#"
404                function main() {
405                    var _0x4e = {a: "log", b: "Hello"};
406                    var _0xc = console;
407                    var _unused = 42;
408                    _0xc[_0x4e["a"]](_0x4e["b"]);
409                }
410                main();
411            "#)
412            .unwrap();
413        // After all transforms: console.log("Hello")
414        assert!(result.code.contains("\"Hello\""), "got: {}", result.code);
415        assert!(!result.code.contains("_unused"), "dead code not removed: {}", result.code);
416    }
417
418    #[test]
419    fn test_target_api() {
420        // Test target API works (doesn't crash)
421        let result = JSDeobfuscator::new()
422            .target(Target::ObfuscatorIO)
423            .deobfuscate("console.log(1 + 2);")
424            .unwrap();
425        assert!(result.code.contains("3"), "got: {}", result.code);
426    }
427}