Repository: vyperlang/vyper

Issue:
arrays nested in complex types can be modified during iteration
### Version Information

* vyper Version (output of `vyper --version`): v0.3.3
* Python Version (output of `python --version`): v3.8.10

### What's your issue about?
the following code compiles, when it shouldn't
```vyper
struct Foo:
    foo: uint256[4] # DynArray[uint256, 100]

my_array2: Foo

@internal
def doStuff(i: uint256) -> uint256:
    self.my_array2.foo[i] = i
    return i

@internal
def _helper():
    i: uint256 = 0
    for item in self.my_array2.foo:
        self.doStuff(i)
        i += 1
```

### How can it be fixed?
related to https://github.com/vyperlang/vyper/issues/2865

probably, this analysis needs to get more sophisticated. right now it looks for AST similarities, but it should probably be based on tagging nodes with modification analysis instead. https://github.com/vyperlang/vyper/blob/1b0521b361b90b9dc356707886730dfacd6631ba/vyper/semantics/validation/local.py#L91-L106


Discussion:

Resolving patch:
diff --git a/vyper/semantics/validation/local.py b/vyper/semantics/validation/local.py
--- a/vyper/semantics/validation/local.py
+++ b/vyper/semantics/validation/local.py
@@ -394,7 +394,14 @@ def visit_For(self, node):
             if assign:
                 raise ImmutableViolation("Cannot modify array during iteration", assign)
 
-        if node.iter.get("value.id") == "self":
+        # Check if `iter` is a storage variable. get_descendants` is used to check for
+        # nested `self` (e.g. structs)
+        iter_is_storage_var = (
+            isinstance(node.iter, vy_ast.Attribute)
+            and len(node.iter.get_descendants(vy_ast.Name, {"id": "self"})) > 0
+        )
+
+        if iter_is_storage_var:
             # check if iterated value may be modified by function calls inside the loop
             iter_name = node.iter.attr
             for call_node in node.get_descendants(vy_ast.Call, {"func.value.id": "self"}):


Test patch:
diff --git a/tests/parser/features/iteration/test_for_in_list.py b/tests/parser/features/iteration/test_for_in_list.py
--- a/tests/parser/features/iteration/test_for_in_list.py
+++ b/tests/parser/features/iteration/test_for_in_list.py
@@ -439,6 +439,80 @@ def data() -> int128:
     """,
         ImmutableViolation,
     ),
+    # alter nested storage list in internal function call within for loop
+    (
+        """
+struct Foo:
+    foo: uint256[4]
+
+my_array2: Foo
+
+@internal
+def doStuff(i: uint256) -> uint256:
+    self.my_array2.foo[i] = i
+    return i
+
+@internal
+def _helper():
+    i: uint256 = 0
+    for item in self.my_array2.foo:
+        self.doStuff(i)
+        i += 1
+    """,
+        ImmutableViolation,
+    ),
+    # alter doubly nested storage list in internal function call within for loop
+    (
+        """
+struct Foo:
+    foo: uint256[4]
+
+struct Bar:
+    bar: Foo
+    baz: uint256
+
+my_array2: Bar
+
+@internal
+def doStuff(i: uint256) -> uint256:
+    self.my_array2.bar.foo[i] = i
+    return i
+
+@internal
+def _helper():
+    i: uint256 = 0
+    for item in self.my_array2.bar.foo:
+        self.doStuff(i)
+        i += 1
+    """,
+        ImmutableViolation,
+    ),
+    # alter entire struct with nested storage list in internal function call within for loop
+    (
+        """
+struct Foo:
+    foo: uint256[4]
+
+my_array2: Foo
+
+@internal
+def doStuff():
+    self.my_array2.foo = [
+        block.timestamp + 1,
+        block.timestamp + 2,
+        block.timestamp + 3,
+        block.timestamp + 4
+    ]
+
+@internal
+def _helper():
+    i: uint256 = 0
+    for item in self.my_array2.foo:
+        self.doStuff()
+        i += 1
+    """,
+        ImmutableViolation,
+    ),
     # invalid nested loop
     (
         """