Commit: patch 9.2.0936: stringifying a list or dict can free the item being iterated
Christian Brabandt <[email protected]> Tue, 11 Aug 2026 20:45:04 +0200
| Newsgroups | gmane.editors.vim.devel |
|---|---|
| Message-ID | <[email protected]> |
patch 9.2.0936: stringifying a list or dict can free the item being iterated Commit: https://github.com/vim/vim/commit/5f54b1dc4cc87e7825f7585dc73a488e67985915 Author: Samuel Schlesinger <[email protected]> Date: Tue Aug 11 18:28:47 2026 +0000 patch 9.2.0936: stringifying a list or dict can free the item being iterated Problem: match(), matchstr(), matchend(), matchlist() and matchstrpos() over a list, join() of a list, and string() or :echo of a list or dict stringify each item while iterating over the container. For an object item this runs the user-defined string() method, which can remove the item the loop is standing on, or grow a dict so its hash table is reallocated, leaving the loop reading freed memory. Solution: Lock the container while iterating, so a change from the string() method fails with E741 instead of corrupting the iterator, the same way filter(), map(), sort() and reduce() already do. For a dict also hash_lock() it, so growing it cannot reallocate the hash table, mirroring the dict path of filter()/map() (Samuel Schlesinger). find_some_match() in evalfunc.c, list_join_inner() in list.c and dict2string() in dict.c each iterated a container while calling echo_string(); list_reduce() was the existing pattern they were missing. Add tests: match() and matchstr() over a list, join() and string() of a list, and string() of a dict, each with an object whose string() method mutates the container; every one crashes an unpatched Vim. closes: #21001 Co-Authored-By: Claude <[email protected]> Signed-off-by: Samuel Schlesinger <[email protected]> Signed-off-by: Christian Brabandt <[email protected]> diff --git a/src/dict.c b/src/dict.c index 5531cc72a..029fcfe78 100644 --- a/src/dict.c +++ b/src/dict.c @@ -809,12 +809,22 @@ dict2string(typval_T *tv, int copyID, int restore_copyID) char_u *s; dict_T *d; int todo; + int prev_lock; if ((d = tv->vval.v_dict) == NULL) return NULL; ga_init2(&ga, sizeof(char), 80); ga_append(&ga, '{'); + // Lock the dictionary, so that user code that echo_string_core() below + // may invoke, such as the string() method of an object, cannot remove + // an item or add one and cause the hash table to be reallocated while + // we are iterating over it. + prev_lock = d->dv_lock; + if (d->dv_lock == 0) + d->dv_lock = VAR_LOCKED; + hash_lock(&d->dv_hashtab); + todo = (int)d->dv_hashtab.ht_used; FOR_ALL_HASHTAB_ITEMS(&d->dv_hashtab, hi, todo) { @@ -845,6 +855,8 @@ dict2string(typval_T *tv, int copyID, int restore_copyID) } } + hash_unlock(&d->dv_hashtab); + d->dv_lock = prev_lock; if (todo > 0) { vim_free(ga.ga_data); diff --git a/src/evalfunc.c b/src/evalfunc.c index 23dc21e95..472b2fe02 100644 --- a/src/evalfunc.c +++ b/src/evalfunc.c @@ -9256,6 +9256,7 @@ find_some_match(typval_T *argvars, typval_T *rettv, matchtype_T type) list_T *l = NULL; listitem_T *li = NULL; long idx = 0; + int prev_lock = 0; char_u *tofree = NULL; // Make 'cpoptions' empty, the 'l' flag should not be used here. @@ -9358,6 +9359,16 @@ find_some_match(typval_T *argvars, typval_T *rettv, matchtype_T type) { regmatch.rm_ic = p_ic; + // Lock the list, so that the item the loop is standing on cannot + // be freed by user code that echo_string() below may invoke: the + // string() method of an object could remove the item. + if (l != NULL) + { + prev_lock = l->lv_lock; + if (l->lv_lock == 0) + l->lv_lock = VAR_LOCKED; + } + for (;;) { if (l != NULL) @@ -9460,6 +9471,8 @@ find_some_match(typval_T *argvars, typval_T *rettv, matchtype_T type) rettv->vval.v_number += (varnumber_T)(str - expr); } } + if (l != NULL) + l->lv_lock = prev_lock; vim_regfree(regmatch.regprog); } diff --git a/src/list.c b/src/list.c index e400cb096..82243c82b 100644 --- a/src/list.c +++ b/src/list.c @@ -1528,6 +1528,7 @@ list_join_inner( join_T *p; long sumlen = 0; int first = TRUE; + int prev_lock; char_u *tofree; char_u numbuf[NUMBUFLEN]; listitem_T *item; @@ -1536,12 +1537,21 @@ list_join_inner( // Stringify each item in the list. CHECK_LIST_MATERIALIZE(l); + // Lock the list, so that the item the loop is standing on cannot be + // freed by user code that echo_string_core() below may invoke: the + // string() method of an object could remove the item. + prev_lock = l->lv_lock; + if (l->lv_lock == 0) + l->lv_lock = VAR_LOCKED; for (item = l->lv_first; item != NULL && !got_int; item = item->li_next) { s.string = echo_string_core(&item->li_tv, &tofree, numbuf, copyID, echo_style, restore_copyID, !echo_style); if (s.string == NULL) + { + l->lv_lock = prev_lock; return FAIL; + } s.length = STRLEN(s.string); sumlen += (long)s.length; @@ -1565,6 +1575,7 @@ list_join_inner( if (did_echo_string_emsg) // recursion error, bail out break; } + l->lv_lock = prev_lock; // Allocate result buffer with its total size, avoid re-allocation and // multiple copy operations. Add 2 for a tailing ']' and NUL. diff --git a/src/testdir/test_functions.vim b/src/testdir/test_functions.vim index 2f2a7fc5f..3aec6c524 100644 --- a/src/testdir/test_functions.vim +++ b/src/testdir/test_functions.vim @@ -1253,6 +1253,89 @@ func Test_matchstrpos() call assert_equal(['', -1, -1], matchstrpos(test_null_list(), ' ')) endfunc +" While match() iterates over a list, stringifying an item can run the +" string() method of an object, which must not be able to free the item +" the loop is standing on. +func Test_match_list_changed_while_matching() + let lines =<< trim END + vim9script + class C + def string(): string + if !g:removed + g:removed = true + remove(g:mlist, 0) + endif + return 'nostring' + enddef + endclass + g:mlist = [C.new(), C.new(), C.new()] + END + call writefile(lines, 'Xmatchmutate.vim', 'D') + let g:removed = v:false + source Xmatchmutate.vim + call assert_fails('call match(g:mlist, "xyz")', 'E741:') + call assert_equal(3, len(g:mlist)) + let g:removed = v:false + call assert_fails('call matchstr(g:mlist, "xyz")', 'E741:') + call assert_equal(3, len(g:mlist)) + unlet g:mlist g:removed +endfunc + +" Same for join() and string(): stringifying an item can run the string() +" method of an object, which must not be able to free the item the loop is +" standing on. +func Test_join_list_changed_while_stringified() + let lines =<< trim END + vim9script + class C + def string(): string + if !g:removed + g:removed = true + remove(g:jlist, 0) + endif + return 'nostring' + enddef + endclass + g:jlist = [C.new(), C.new(), C.new()] + END + call writefile(lines, 'Xjoinmutate.vim', 'D') + let g:removed = v:false + source Xjoinmutate.vim + call assert_fails('call join(g:jlist, ",")', 'E741:') + call assert_equal(3, len(g:jlist)) + let g:removed = v:false + call assert_fails('call string(g:jlist)', 'E741:') + call assert_equal(3, len(g:jlist)) + unlet g:jlist g:removed +endfunc + +" Same for a dict: stringifying a value can run the string() method of an +" object, which must not be able to remove an item and free it, or grow the +" dict and reallocate the hash table, while it is being iterated over. +func Test_dict_changed_while_stringified() + let lines =<< trim END + vim9script + class C + def string(): string + if !g:removed + g:removed = true + for k in keys(g:d) + remove(g:d, k) + endfor + endif + return 'nostring' + enddef + endclass + g:d = {'a': C.new(), 'b': C.new(), 'c': C.new()} + END + call writefile(lines, 'Xdictmutate.vim', 'D') + let g:removed = v:false + source Xdictmutate.vim + call assert_fails('call string(g:d)', 'E741:') + call assert_equal(3, len(g:d)) + unlet g:d g:removed +endfunc + " Test for matchstrlist() func Test_matchstrlist() let lines =<< trim END diff --git a/src/version.c b/src/version.c index 45ddeda96..2cbf92a2e 100644 --- a/src/version.c +++ b/src/version.c @@ -763,6 +763,8 @@ static char *(features[]) = static int included_patches[] = { /* Add new patch number below this line */ +/**/ + 936, /**/ 935, /**/ -- -- You received this message from the "vim_dev" maillist. Do not top-post! Type your reply below the text you are replying to. For more information, visit http://www.vim.org/maillist.php --- You received this message because you are subscribed to the Google Groups "vim_dev" group. To unsubscribe from this group and stop receiving emails from it, send an email to [email protected]. To view this discussion visit https://groups.google.com/d/msgid/vim_dev/E1wtrTE-007Wn3-4d%40256bit.org.