1#![doc = include_str!("../README.md")]
2
3use std::collections::{HashMap, HashSet};
10
11use rhai::{AST, ASTNode, Expr, Stmt};
12
13#[derive(Debug, Default)]
15pub struct ScriptAnalysisResult {
16 pub accessed_variables: HashSet<String>,
19
20 pub local_variables: HashSet<String>,
23
24 pub string_comparisons: HashMap<String, HashSet<String>>,
30}
31
32pub trait RhaiAnalyzerExt {
49 fn analyze(&self) -> ScriptAnalysisResult;
51
52 fn accessed_variables(&self) -> HashSet<String> {
55 self.analyze().accessed_variables
56 }
57
58 fn string_comparisons_for(&self, variable_path: &str) -> HashSet<String> {
61 self.analyze()
62 .string_comparisons
63 .remove(variable_path)
64 .unwrap_or_default()
65 }
66}
67
68impl RhaiAnalyzerExt for AST {
69 fn analyze(&self) -> ScriptAnalysisResult {
70 analyze_ast(self)
71 }
72}
73
74pub fn analyze_ast(ast: &AST) -> ScriptAnalysisResult {
78 let mut result = ScriptAnalysisResult::default();
79
80 ast.walk(&mut |nodes: &[ASTNode]| {
81 let parent = nodes.len().checked_sub(2).map(|i| &nodes[i]);
82
83 match nodes.last().unwrap() {
84 ASTNode::Stmt(stmt) => match *stmt {
88 Stmt::Var(var_def, _, _) => {
90 result.local_variables.insert(var_def.0.name.to_string());
91 }
92 Stmt::For(for_loop, _) => {
93 result.local_variables.insert(for_loop.0.name.to_string());
94 if let Some(counter) = &for_loop.1 {
95 result.local_variables.insert(counter.name.to_string());
96 }
97 }
98 Stmt::FnCall(fn_call, _)
102 if fn_call.namespace.is_empty() && fn_call.args.len() == 2 =>
103 {
104 match fn_call.name.as_str() {
105 "==" | "!=" => {
106 record_string_comparison(
107 &fn_call.args[0],
108 &fn_call.args[1],
109 &mut result,
110 );
111 record_string_comparison(
112 &fn_call.args[1],
113 &fn_call.args[0],
114 &mut result,
115 );
116 }
117 _ => {}
118 }
119 }
120 _ => {}
121 },
122
123 ASTNode::Expr(expr) => {
127 if let Expr::FnCall(fn_call, _) = *expr
130 && fn_call.namespace.is_empty()
131 && fn_call.args.len() == 2
132 {
133 match fn_call.name.as_str() {
134 "==" | "!=" => {
135 record_string_comparison(
136 &fn_call.args[0],
137 &fn_call.args[1],
138 &mut result,
139 );
140 record_string_comparison(
141 &fn_call.args[1],
142 &fn_call.args[0],
143 &mut result,
144 );
145 }
146 _ => {}
147 }
148 }
149
150 if !matches!(*expr, Expr::Property(..))
156 && let Some(path) = get_full_variable_path(expr)
157 {
158 if !parent_subsumes(parent) {
159 result.accessed_variables.insert(path);
160 }
161 if let Expr::Index(bin, _, _) = *expr
164 && let Some(idx_path) = get_full_variable_path(&bin.rhs)
165 {
166 result.accessed_variables.insert(idx_path);
167 }
168 }
169 }
170
171 _ => {}
172 }
173
174 true
175 });
176
177 result
178}
179
180fn parent_subsumes(parent: Option<&ASTNode>) -> bool {
193 match parent {
194 Some(ASTNode::Expr(parent_expr)) => match *parent_expr {
195 Expr::Dot(..) => is_variable_path(parent_expr),
196 Expr::Index(..) => true,
197 _ => false,
198 },
199 _ => false,
200 }
201}
202
203fn is_variable_path(expr: &Expr) -> bool {
207 match expr {
208 Expr::Dot(bin, _, _) => is_variable_path(&bin.lhs) && is_variable_path(&bin.rhs),
209 Expr::Property(..) | Expr::Variable(..) => true,
210 Expr::Index(bin, _, _) => is_variable_path(&bin.lhs),
211 _ => false,
212 }
213}
214
215fn get_full_variable_path(expr: &Expr) -> Option<String> {
222 fn collect_path(expr: &Expr, parts: &mut Vec<String>) -> bool {
223 match expr {
224 Expr::Dot(binary_expr, _, _) => {
225 collect_path(&binary_expr.lhs, parts) && collect_path(&binary_expr.rhs, parts)
226 }
227 Expr::Property(prop_info, _) => {
228 parts.push(prop_info.2.to_string());
229 true
230 }
231 Expr::Variable(var_info, _, _) => {
232 parts.push(var_info.1.to_string());
233 true
234 }
235 Expr::Index(binary_expr, _, _) => collect_path(&binary_expr.lhs, parts),
236 _ => false,
237 }
238 }
239
240 let mut path_parts = Vec::new();
241 if collect_path(expr, &mut path_parts) && !path_parts.is_empty() {
242 Some(path_parts.join("."))
243 } else {
244 None
245 }
246}
247
248fn record_string_comparison(lhs: &Expr, rhs: &Expr, result: &mut ScriptAnalysisResult) {
255 if let Some(var_path) = get_full_variable_path(lhs)
256 && let Expr::StringConstant(string_val, _) = rhs
257 {
258 result
259 .string_comparisons
260 .entry(var_path)
261 .or_default()
262 .insert(string_val.to_string());
263 }
264}
265
266#[cfg(test)]
271mod tests {
272 use rhai::{Engine, ParseError};
273
274 use super::*;
275
276 fn compile(script: &str) -> Result<rhai::AST, ParseError> {
277 Engine::new().compile(script)
278 }
279
280 fn analyze_script(script: &str) -> Result<ScriptAnalysisResult, ParseError> {
281 Ok(analyze_ast(&compile(script)?))
282 }
283
284 #[test]
289 fn test_trait_accessed_variables() {
290 let ast = compile("if user.balance > 100 { send_alert(user.email); }").unwrap();
291 let vars = ast.accessed_variables();
292 assert!(vars.contains("user.balance"));
293 assert!(vars.contains("user.email"));
294 }
295
296 #[test]
297 fn test_trait_string_comparisons_for() {
298 let ast = compile(r#"log.name == "Transfer" || log.name == "Approval""#).unwrap();
299 let vals = ast.string_comparisons_for("log.name");
300 assert_eq!(
301 vals,
302 HashSet::from(["Transfer".to_string(), "Approval".to_string()])
303 );
304 }
305
306 #[test]
307 fn test_trait_string_comparisons_for_missing() {
308 let ast = compile("tx.value > 100").unwrap();
309 assert!(ast.string_comparisons_for("tx.value").is_empty());
310 }
311
312 #[test]
313 fn test_trait_analyze_returns_full_result() {
314 let ast = compile(r#"let x = 1; log.name == "Transfer""#).unwrap();
315 let result = ast.analyze();
316 assert!(result.local_variables.contains("x"));
317 assert!(result.string_comparisons.contains_key("log.name"));
318 }
319
320 #[test]
321 fn test_simple_binary_op() {
322 let result = analyze_script("tx.value > 100").unwrap();
323 assert_eq!(
324 result.accessed_variables,
325 HashSet::from(["tx.value".to_string()])
326 );
327 }
328
329 #[test]
330 fn test_logical_operators() {
331 let script = r#"tx.from == owner && log.name != "Transfer" || block.number > 1000"#;
332 let result = analyze_script(script).unwrap();
333 assert_eq!(
334 result.accessed_variables,
335 HashSet::from([
336 "tx.from".to_string(),
337 "owner".to_string(),
338 "log.name".to_string(),
339 "block.number".to_string(),
340 ])
341 );
342 }
343
344 #[test]
345 fn test_multiple_variables_and_coalesce() {
346 let result = analyze_script("tx.from ?? fallback_addr.address").unwrap();
347 assert_eq!(
348 result.accessed_variables,
349 HashSet::from(["tx.from".to_string(), "fallback_addr.address".to_string()])
350 );
351 }
352
353 #[test]
354 fn test_deeply_nested_variable() {
355 let script = r#"log.params.level_one.level_two.user == "admin""#;
356 let result = analyze_script(script).unwrap();
357 assert_eq!(
358 result.accessed_variables,
359 HashSet::from(["log.params.level_one.level_two.user".to_string()])
360 );
361 }
362
363 #[test]
364 fn test_variables_in_function_calls() {
365 let result = analyze_script("my_func(tx.value, log.params.user, 42)").unwrap();
366 assert_eq!(
367 result.accessed_variables,
368 HashSet::from(["tx.value".to_string(), "log.params.user".to_string()])
369 );
370 }
371
372 #[test]
373 fn test_variables_in_let_and_if() {
374 let script = r#"
375 let threshold = config.min_value;
376 if tx.value > threshold && tx.to != blacklist.address {
377 true
378 } else {
379 false
380 }
381 "#;
382 let result = analyze_script(script).unwrap();
383 assert_eq!(
384 result.accessed_variables,
385 HashSet::from([
386 "config.min_value".to_string(),
387 "tx.value".to_string(),
388 "threshold".to_string(),
389 "tx.to".to_string(),
390 "blacklist.address".to_string()
391 ])
392 );
393 }
394
395 #[test]
396 fn test_variables_in_loops() {
397 let script = r#"
398 for item in tx.items {
399 if item.cost > max_cost {
400 return false;
401 }
402 }
403 while x < limit {
404 x = x + 1;
405 }
406 "#;
407 let result = analyze_script(script).unwrap();
408 assert_eq!(
409 result.accessed_variables,
410 HashSet::from([
411 "tx.items".to_string(),
412 "item.cost".to_string(),
413 "max_cost".to_string(),
414 "x".to_string(),
415 "limit".to_string(),
416 ])
417 );
418 }
419
420 #[test]
421 fn test_variables_in_strings_or_comments_are_ignored() {
422 let script = r#"
423 // This is a comment about tx.value
424 let x = "this string mentions log.name";
425 tx.from == "0x123"
426 "#;
427 let result = analyze_script(script).unwrap();
428 assert_eq!(
429 result.accessed_variables,
430 HashSet::from(["tx.from".to_string()])
431 );
432 }
433
434 #[test]
435 fn test_indexing_expression() {
436 let script = r#"tx.logs[0].name == "Transfer" && some_array[tx.index] > 100"#;
437 let result = analyze_script(script).unwrap();
438 assert_eq!(
439 result.accessed_variables,
440 HashSet::from([
441 "tx.logs".to_string(),
442 "some_array".to_string(),
443 "tx.index".to_string(),
444 ])
445 );
446 }
447
448 #[test]
449 fn test_method_calls() {
450 let script = r#"my_array.contains(tx.value) && other_var.to_string() == "hello""#;
451 let result = analyze_script(script).unwrap();
452 assert_eq!(
453 result.accessed_variables,
454 HashSet::from([
455 "my_array".to_string(),
456 "tx.value".to_string(),
457 "other_var".to_string(),
458 ])
459 );
460 }
461
462 #[test]
463 fn test_switch_statement() {
464 let script = r#"
465 switch tx.action {
466 "transfer" => do_transfer(log.params.amount),
467 "approve" if log.approved => do_approve(),
468 _ => do_nothing(contract.address)
469 }
470 "#;
471 let result = analyze_script(script).unwrap();
472 assert_eq!(
473 result.accessed_variables,
474 HashSet::from([
475 "tx.action".to_string(),
476 "log.params.amount".to_string(),
477 "log.approved".to_string(),
478 "contract.address".to_string(),
479 ])
480 );
481 }
482
483 #[test]
484 fn test_no_variables() {
485 let result = analyze_script("1 + 1 == 2").unwrap();
486 assert!(result.accessed_variables.is_empty());
487 }
488
489 #[test]
490 fn test_array_and_map_literals() {
491 let script = r#"
492 let my_array = [tx.value, log.topic];
493 let my_map = #{ a: some.value, b: 42 };
494 my_array[0] > my_map.a
495 "#;
496 let result = analyze_script(script).unwrap();
497 assert_eq!(
498 result.accessed_variables,
499 HashSet::from([
500 "tx.value".to_string(),
501 "log.topic".to_string(),
502 "some.value".to_string(),
503 "my_array".to_string(),
504 "my_map.a".to_string(),
505 ])
506 );
507 }
508
509 #[test]
510 fn test_string_comparison_simple() {
511 let result = analyze_script(r#"log.name == "Transfer""#).unwrap();
512 assert_eq!(
513 result.accessed_variables,
514 HashSet::from(["log.name".to_string()])
515 );
516 let names = result.string_comparisons.get("log.name").unwrap();
517 assert_eq!(names, &HashSet::from(["Transfer".to_string()]));
518 }
519
520 #[test]
521 fn test_string_comparison_reversed() {
522 let result = analyze_script(r#""Approval" == log.name"#).unwrap();
523 let names = result.string_comparisons.get("log.name").unwrap();
524 assert_eq!(names, &HashSet::from(["Approval".to_string()]));
525 }
526
527 #[test]
528 fn test_string_comparison_in_logical_or() {
529 let result = analyze_script(r#"tx.value > 100 || log.name == "Deposit""#).unwrap();
530 let names = result.string_comparisons.get("log.name").unwrap();
531 assert_eq!(names, &HashSet::from(["Deposit".to_string()]));
532 }
533
534 #[test]
535 fn test_string_comparison_multiple_values() {
536 let result = analyze_script(r#"log.name == "Transfer" || log.name == "Approval""#).unwrap();
537 let names = result.string_comparisons.get("log.name").unwrap();
538 assert_eq!(
539 names,
540 &HashSet::from(["Transfer".to_string(), "Approval".to_string()])
541 );
542 }
543
544 #[test]
545 fn test_string_comparison_inequality() {
546 let result = analyze_script(r#"log.name != "Transfer""#).unwrap();
547 let names = result.string_comparisons.get("log.name").unwrap();
548 assert_eq!(names, &HashSet::from(["Transfer".to_string()]));
549 }
550
551 #[test]
552 fn test_string_comparison_different_path() {
553 let result = analyze_script(r#"tx.status == "success" && tx.type != "mint""#).unwrap();
554 let statuses = result.string_comparisons.get("tx.status").unwrap();
555 assert_eq!(statuses, &HashSet::from(["success".to_string()]));
556 let types = result.string_comparisons.get("tx.type").unwrap();
557 assert_eq!(types, &HashSet::from(["mint".to_string()]));
558 }
559}