|
1 | 1 | use crate::core::stack::StackFrame; |
2 | 2 |
|
| 3 | +/// Check if the stack diff and condition show evidence of iteration. |
| 4 | +/// A real loop must have: |
| 5 | +/// 1. A non-empty stack diff (something is changing) |
| 6 | +/// 2. The diff shows meaningful iteration patterns OR |
| 7 | +/// 3. The condition is NOT a storage-to-argument comparison (balance check) |
| 8 | +/// |
| 9 | +/// This function is conservative - it returns false for patterns that look like |
| 10 | +/// `require()` checks (storage compared to function arguments). |
| 11 | +pub(crate) fn stack_diff_shows_iteration(stack_diff: &[StackFrame], condition: &str) -> bool { |
| 12 | + // Empty diff means no iteration - the stack is identical |
| 13 | + if stack_diff.is_empty() { |
| 14 | + return false; |
| 15 | + } |
| 16 | + |
| 17 | + // First, check if the condition looks like a balance/require check |
| 18 | + // These are NOT loops regardless of stack diff |
| 19 | + if looks_like_require_check(condition) { |
| 20 | + return false; |
| 21 | + } |
| 22 | + |
| 23 | + // Look for increment/decrement patterns in the stack diff operations |
| 24 | + for frame in stack_diff { |
| 25 | + let solidified = frame.operation.solidify(); |
| 26 | + |
| 27 | + // Check for common loop counter patterns in the operation expression |
| 28 | + if solidified.contains(" + 0x01") |
| 29 | + || solidified.contains(" + 1)") |
| 30 | + || solidified.contains(" - 0x01") |
| 31 | + || solidified.contains(" - 1)") |
| 32 | + || solidified.contains(" + 0x20") |
| 33 | + || solidified.contains(" + 32)") |
| 34 | + || solidified.contains(" - 0x20") |
| 35 | + || solidified.contains(" - 32)") |
| 36 | + { |
| 37 | + return true; |
| 38 | + } |
| 39 | + } |
| 40 | + |
| 41 | + // If we have a non-empty diff but no clear increment pattern, |
| 42 | + // check if the condition uses a simple counter variable |
| 43 | + if condition_has_simple_counter(condition) { |
| 44 | + return true; |
| 45 | + } |
| 46 | + |
| 47 | + false |
| 48 | +} |
| 49 | + |
| 50 | +/// Check if a condition looks like a require/balance check. |
| 51 | +/// These patterns compare storage values to function arguments and are NOT loops. |
| 52 | +fn looks_like_require_check(condition: &str) -> bool { |
| 53 | + // Strip outer parens and negations to get to the core comparison |
| 54 | + let inner = strip_negations_and_parens(condition); |
| 55 | + |
| 56 | + // Check if it's a storage-to-argument comparison |
| 57 | + for op in [" < ", " > ", " <= ", " >= "] { |
| 58 | + if let Some(pos) = inner.find(op) { |
| 59 | + let lhs = inner[..pos].trim(); |
| 60 | + let rhs = inner[pos + op.len()..].trim(); |
| 61 | + |
| 62 | + // storage[...] compared to argN is a balance check |
| 63 | + let lhs_storage = lhs.contains("storage["); |
| 64 | + let rhs_storage = rhs.contains("storage["); |
| 65 | + let lhs_arg = is_arg_ref(lhs); |
| 66 | + let rhs_arg = is_arg_ref(rhs); |
| 67 | + |
| 68 | + if (lhs_storage && rhs_arg) || (lhs_arg && rhs_storage) { |
| 69 | + return true; |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + false |
| 75 | +} |
| 76 | + |
| 77 | +/// Check if a condition uses a simple counter variable pattern. |
| 78 | +/// Loop conditions like "i < length" or "0x20 < memory[...].length" are valid. |
| 79 | +/// Also handles inverted patterns like "length > i" or "0 > 0x01". |
| 80 | +fn condition_has_simple_counter(condition: &str) -> bool { |
| 81 | + let inner = strip_negations_and_parens(condition); |
| 82 | + |
| 83 | + // Look for patterns where one side is a simple counter value |
| 84 | + // Patterns: "counter < limit", "counter <= limit", "limit > counter", "limit >= counter" |
| 85 | + for op in [" < ", " <= ", " > ", " >= "] { |
| 86 | + if let Some(pos) = inner.find(op) { |
| 87 | + let lhs = inner[..pos].trim(); |
| 88 | + let rhs = inner[pos + op.len()..].trim(); |
| 89 | + |
| 90 | + // For < and <=, the counter is on the left |
| 91 | + // For > and >=, the counter is on the right |
| 92 | + let (counter_side, _limit_side) = if op.contains('<') { |
| 93 | + (lhs, rhs) |
| 94 | + } else { |
| 95 | + (rhs, lhs) |
| 96 | + }; |
| 97 | + |
| 98 | + // Counter should be simple (hex constant, decimal, or simple variable) |
| 99 | + // and NOT a storage access |
| 100 | + if !counter_side.contains("storage[") && !counter_side.contains("keccak") { |
| 101 | + // Check if it's a small constant (likely a counter) or simple var |
| 102 | + if is_small_constant(counter_side) || is_simple_var(counter_side) { |
| 103 | + return true; |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + false |
| 110 | +} |
| 111 | + |
| 112 | +/// Strip leading negations and outer parentheses from a condition |
| 113 | +fn strip_negations_and_parens(condition: &str) -> &str { |
| 114 | + let mut s = condition.trim(); |
| 115 | + loop { |
| 116 | + let prev = s; |
| 117 | + if s.starts_with('!') { |
| 118 | + s = s[1..].trim(); |
| 119 | + } |
| 120 | + if s.starts_with('(') && s.ends_with(')') { |
| 121 | + // Check if parens are balanced |
| 122 | + let inner = &s[1..s.len() - 1]; |
| 123 | + if inner.chars().filter(|&c| c == '(').count() |
| 124 | + == inner.chars().filter(|&c| c == ')').count() |
| 125 | + { |
| 126 | + s = inner.trim(); |
| 127 | + } else { |
| 128 | + break; |
| 129 | + } |
| 130 | + } |
| 131 | + if s == prev { |
| 132 | + break; |
| 133 | + } |
| 134 | + } |
| 135 | + s |
| 136 | +} |
| 137 | + |
| 138 | +/// Check if expression is a function argument reference (argN) |
| 139 | +fn is_arg_ref(s: &str) -> bool { |
| 140 | + let trimmed = s.trim().trim_start_matches('(').trim_end_matches(')').trim(); |
| 141 | + if let Some(rest) = trimmed.strip_prefix("arg") { |
| 142 | + return rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty(); |
| 143 | + } |
| 144 | + false |
| 145 | +} |
| 146 | + |
| 147 | +/// Check if a string is a small constant (likely a loop counter value) |
| 148 | +fn is_small_constant(s: &str) -> bool { |
| 149 | + let trimmed = s.trim().trim_start_matches('(').trim_end_matches(')').trim(); |
| 150 | + // Hex constants like 0x20, 0x40, 0x00 |
| 151 | + if let Some(hex) = trimmed.strip_prefix("0x") { |
| 152 | + if let Ok(val) = u64::from_str_radix(hex, 16) { |
| 153 | + return val <= 0x1000; // Reasonable loop counter range |
| 154 | + } |
| 155 | + } |
| 156 | + // Decimal constants |
| 157 | + if let Ok(val) = trimmed.parse::<u64>() { |
| 158 | + return val <= 4096; |
| 159 | + } |
| 160 | + false |
| 161 | +} |
| 162 | + |
| 163 | +/// Check if expression looks like a simple variable (not complex expression) |
| 164 | +fn is_simple_var(s: &str) -> bool { |
| 165 | + let trimmed = s.trim().trim_start_matches('(').trim_end_matches(')').trim(); |
| 166 | + // Simple patterns: i, j, var_a, memory[0x40], etc. but not complex expressions |
| 167 | + !trimmed.contains(" + ") |
| 168 | + && !trimmed.contains(" - ") |
| 169 | + && !trimmed.contains(" * ") |
| 170 | + && !trimmed.contains(" / ") |
| 171 | + && !trimmed.contains("storage[") |
| 172 | + && !trimmed.contains("keccak") |
| 173 | +} |
| 174 | + |
3 | 175 | /// Check if a condition is tautologically true (e.g., "arg0 == arg0", "X == (address(X))"). |
4 | 176 | /// These create infinite loops and should be skipped as invalid loop conditions. |
5 | 177 | /// Also handles bitmask patterns like "X == (X & 0xff...ff)" which are type-check equivalents. |
@@ -170,19 +342,25 @@ fn is_balanced_parens(s: &str) -> bool { |
170 | 342 |
|
171 | 343 | /// Check if a condition is tautologically false (e.g., "0 > 1", "(0 > 0x01)"). |
172 | 344 | /// These cannot be valid loop conditions and should be skipped. |
| 345 | +/// NOTE: We do NOT strip leading negation here because `!(0 > 1)` = TRUE, which is valid. |
173 | 346 | pub(crate) fn is_tautologically_false_condition(condition: &str) -> bool { |
174 | 347 | // Strip outer whitespace first |
175 | 348 | let mut trimmed = condition.trim(); |
176 | 349 |
|
177 | | - // Remove leading negation (!) - we're looking at the inner condition |
| 350 | + // If condition starts with negation, it's NOT tautologically false |
| 351 | + // because !(false) = true, which is a valid (though potentially infinite) loop |
178 | 352 | if trimmed.starts_with('!') { |
179 | | - trimmed = trimmed[1..].trim(); |
| 353 | + return false; |
180 | 354 | } |
181 | 355 |
|
182 | 356 | // Strip all outer parentheses (could be nested like "((...))") |
183 | 357 | while trimmed.starts_with('(') && trimmed.ends_with(')') { |
184 | | - trimmed = &trimmed[1..trimmed.len() - 1]; |
185 | | - trimmed = trimmed.trim(); |
| 358 | + let inner = &trimmed[1..trimmed.len() - 1]; |
| 359 | + if is_balanced_parens(inner) { |
| 360 | + trimmed = inner.trim(); |
| 361 | + } else { |
| 362 | + break; |
| 363 | + } |
186 | 364 | } |
187 | 365 |
|
188 | 366 | // Pattern: "0 > X" where X > 0 (always false for unsigned) |
|
0 commit comments