From 25f7c87a702fa246004dad85c93d9df421c22ad1 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Sun, 30 Aug 2026 15:50:09 +0200 Subject: [PATCH] fix(mappings): replaying a deleted Lua mapping is UB Problem: Replaying a deleted Lua mapping, may call an arbitrary function. RHS of a Lua mapping embeds its LuaRef (``). The raw keys may outlive the ref (redobuff ".", CmdAtom `keys`). If the mapping is deleted, replaying it either (1) dereferences a freed registry slot, or (2) calls whatever callback reused the slot (autocmd, timer, other mapping). Solution: Assign a monotonic (never recycled) id to Lua mappings and encode the mapping keys as ``. Note: in the case of Vimscript, a deleted function raises E117, but if the function is redefined with the same name, the mapping will find it. Alternatives?: - Globally ensure `LuaRef` ids are not recycled. - Problem: could exhaust `int` in a long-lived Nvim session? Also, difficult to impl bc the "recycling" is done by `luaL_ref` itself. ref: 5ac2e47acc999472042df4f10f8f7b5ffa72ba3e --- runtime/doc/message.txt | 6 ++++ src/nvim/buffer_defs.h | 8 ++--- src/nvim/input.c | 19 ++++++++---- src/nvim/input_cmdatom.c | 3 +- src/nvim/main.c | 1 + src/nvim/mapping.c | 41 +++++++++++++++++++++---- src/nvim/mapping.h | 3 ++ src/nvim/ops.h | 2 -- test/functional/editor/cmdatom_spec.lua | 31 +++++++++++++++++-- 9 files changed, 93 insertions(+), 21 deletions(-) diff --git a/runtime/doc/message.txt b/runtime/doc/message.txt index b4b274915df..dadda5e439b 100644 --- a/runtime/doc/message.txt +++ b/runtime/doc/message.txt @@ -833,6 +833,12 @@ compilation error message. Failed to execute a Lua debug command string. {error} contains the runtime error message and traceback. + *E5117* > + Lua mapping no longer exists + +Replayed keys (|.|, |CmdAtom| `keys`, or keys captured by |vim.on_key()|) +referenced a Lua-callback mapping that was deleted or redefined since. + ============================================================================== Messages *messages* diff --git a/src/nvim/buffer_defs.h b/src/nvim/buffer_defs.h index 2699e1d97b7..9290176974d 100644 --- a/src/nvim/buffer_defs.h +++ b/src/nvim/buffer_defs.h @@ -463,13 +463,13 @@ struct file_buffer { // bitset with 4*64=256 bits: 1 bit per character 0-255. uint64_t b_chartab[4]; - // Table used for mappings local to a buffer. + // Buffer-local mappings. mapblock_T *(b_maphash[MAX_MAPHASH]); - - // First abbreviation local to a buffer. + // Buffer-local abbreviations. mapblock_T *b_first_abbr; - // User commands local to the buffer. + // Buffer-local user commands. garray_T b_ucmds; + // start and end of an operator, also used for '[ and '] pos_T b_op_start; pos_T b_op_start_orig; // used for Ins.start_orig diff --git a/src/nvim/input.c b/src/nvim/input.c index 150d6c6a345..8449156ab06 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -196,6 +196,7 @@ static const char e_cmd_mapping_must_end_with_cr[] = N_("E1255: mapping must end with "); static const char e_cmd_mapping_must_end_with_cr_before_second_cmd[] = N_("E1136: mapping must end with before second "); +static const char e_lua_mapping_gone[] = N_("E5117: Lua mapping no longer exists"); /// Frees the buffer's memory; it becomes empty. static void free_buff(StuffBuf *buf) @@ -3418,12 +3419,12 @@ char *getcmdkeycmd(int promptc, void *cookie, int indent, bool do_concat) return line_ga.ga_data; } -/// Handle a Lua mapping: get its LuaRef from typeahead and execute it. +/// Handle a Lua mapping: get its id from typeahead and execute its callback. /// -/// @param may_repeat save the LuaRef for redoing with "." later -/// @param discard discard the keys instead of executing the LuaRef +/// @param may_repeat Save the mapping-id for redoing with "." later +/// @param discard Discard the keys instead of executing the callback. /// -/// @return false if getting the LuaRef was aborted, true otherwise +/// @return false if getting the id was aborted or mapping no longer exists, true otherwise. bool map_execute_lua(bool may_repeat, bool discard) { garray_T line_ga; @@ -3455,9 +3456,15 @@ bool map_execute_lua(bool may_repeat, bool discard) return !aborted; } - LuaRef ref = (LuaRef)atoi(line_ga.ga_data); + const int id = atoi(line_ga.ga_data); + const LuaRef ref = map_luaid_get(id); + if (ref == LUA_NOREF) { + ga_clear(&line_ga); + emsg(_(e_lua_mapping_gone)); + return false; + } if (may_repeat) { - repeat_luaref = ref; + repeat_luamap = id; } Error err = ERROR_INIT; diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 8c03d3b096d..c02f6fa85aa 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -28,6 +28,7 @@ #include "nvim/keycodes.h" #include "nvim/log.h" #include "nvim/macros_defs.h" +#include "nvim/mapping.h" #include "nvim/mbyte.h" #include "nvim/memory.h" #include "nvim/normal.h" @@ -1120,7 +1121,7 @@ void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank) } } else if (cap->cmdchar == K_LUA) { char buf[NUMBUFLEN]; - redo_append_str(buf, snprintf(buf, sizeof(buf), "%d", repeat_luaref)); + redo_append_str(buf, snprintf(buf, sizeof(buf), "%d", repeat_luamap)); redo_append_str(S_LEN(NL_STR)); } } else if (Visual.active && redoable && oap->motion_force == NUL) { diff --git a/src/nvim/main.c b/src/nvim/main.c index 0e3ed4ed5e3..d0be382c81b 100644 --- a/src/nvim/main.c +++ b/src/nvim/main.c @@ -71,6 +71,7 @@ #include "nvim/lua/treesitter.h" #include "nvim/macros_defs.h" #include "nvim/main.h" +#include "nvim/mapping.h" #include "nvim/mark.h" #include "nvim/memline.h" #include "nvim/memory.h" diff --git a/src/nvim/mapping.c b/src/nvim/mapping.c index f3381770cf4..51b1ee4589a 100644 --- a/src/nvim/mapping.c +++ b/src/nvim/mapping.c @@ -60,11 +60,11 @@ #include "nvim/ui_defs.h" #include "nvim/vim_defs.h" -/// List used for abbreviations. -static mapblock_T *first_abbr = NULL; // first entry in abbrlist +/// Global abbreviations (first entry in the linkedlist). +static mapblock_T *first_abbr = NULL; -// Each mapping is put in one of the MAX_MAPHASH hash lists, -// to speed up finding it. +/// Global mappings. +/// Each mapping is put in one of the MAX_MAPHASH hash lists, to speed up finding it. static mapblock_T *(maphash[MAX_MAPHASH]) = { 0 }; // Make a hash value for a mapping. @@ -146,6 +146,34 @@ mapblock_T *get_buf_maphash_list(int state, int c) return curbuf->b_maphash[MAP_HASH(state, c)]; } +/// Finds the Lua mapping whose `m_str` embeds `id`. +static mapblock_T *map_luaid_scan(mapblock_T *const *mappings, size_t size, int id) +{ + for (size_t i = 0; i < size; i++) { + for (mapblock_T *mp = mappings[i]; mp != NULL; mp = mp->m_next) { + // Find `id` in "". + if (mp->m_luaref != LUA_NOREF && atoi(mp->m_str + 3) == id) { + return mp; + } + } + } + return NULL; +} + +/// Resolves a Lua-mapping id to its callback. +/// +/// @return Lua callback, or LUA_NOREF if the mapping no longer exists. +LuaRef map_luaid_get(int n) +{ + mapblock_T *mp = map_luaid_scan(maphash, MAX_MAPHASH, n); + mp = mp != NULL ? mp : map_luaid_scan(&first_abbr, 1, n); + FOR_ALL_BUFFERS(fb) { + mp = mp != NULL ? mp : map_luaid_scan(fb->b_maphash, MAX_MAPHASH, n); + mp = mp != NULL ? mp : map_luaid_scan(&fb->b_first_abbr, 1, n); + } + return mp != NULL ? mp->m_luaref : LUA_NOREF; +} + /// Delete one entry from the abbrlist or maphash[]. /// "mpp" is a pointer to the m_next field of the PREVIOUS entry! static void mapblock_free(mapblock_T **mpp) @@ -371,9 +399,10 @@ static void set_maparg_rhs(const char *const orig_rhs, const size_t orig_rhs_len // orig_rhs is not used for Lua mappings, but still needs to be a string. mapargs->orig_rhs = xcalloc(1, sizeof(char)); mapargs->orig_rhs_len = 0; - // stores ref_no in map_str + // Stores "" in map_str. + static int map_luaid = 0; mapargs->rhs_len = (size_t)vim_snprintf(S_LEN(tmp_buf), "%c%c%c%d\r", K_SPECIAL, - KS_EXTRA, KE_LUA, rhs_lua); + KS_EXTRA, KE_LUA, ++map_luaid); mapargs->rhs = xstrdup(tmp_buf); } } diff --git a/src/nvim/mapping.h b/src/nvim/mapping.h index 55e403bba26..47cf221f133 100644 --- a/src/nvim/mapping.h +++ b/src/nvim/mapping.h @@ -8,6 +8,7 @@ #include "nvim/cmdexpand_defs.h" // IWYU pragma: keep #include "nvim/eval/typval_defs.h" // IWYU pragma: keep #include "nvim/ex_cmds_defs.h" // IWYU pragma: keep +#include "nvim/macros_defs.h" #include "nvim/mapping_defs.h" // IWYU pragma: keep #include "nvim/option_defs.h" // IWYU pragma: keep #include "nvim/regexp_defs.h" // IWYU pragma: keep @@ -15,6 +16,8 @@ #include "mapping.h.generated.h" +EXTERN int repeat_luamap INIT( = 0); ///< Lua-mapping id for dot-repeat. 0: none. + /// Used for the first argument of do_map() enum { MAPTYPE_MAP = 0, diff --git a/src/nvim/ops.h b/src/nvim/ops.h index 7c67dc03ad7..a6f9ef07692 100644 --- a/src/nvim/ops.h +++ b/src/nvim/ops.h @@ -52,5 +52,3 @@ enum { #include "ops.h.generated.h" #include "ops.h.inline.generated.h" - -EXTERN LuaRef repeat_luaref INIT( = LUA_NOREF); ///< LuaRef for "." diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index ae361ff6ab4..8de3e4e42d7 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -1140,10 +1140,37 @@ describe('CmdAtom', function() }, atoms_tail(2, 'type', 'keys', 'lhs')) end) + it('replay of deleted/redefined Lua mapping fails (E5117)', function() + n.exec_lua([[ + vim.keymap.set('o', 'gt', function() + vim.cmd('normal! viw') + end) + ]]) + fn.setline(1, { 'k1 k2', 'k3 k4' }) + atoms_start() + feed('gg0dgt') + eq(' k2', fn.getline(1)) + local ev = atom_last() + n.exec_lua([[ + vim.keymap.del('o', 'gt') + vim.keymap.set('o', 'gt', function() + vim.cmd('normal! viw') + end) + ]]) + feed('j0') + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(ev.keys)) + eq('k3 k4', fn.getline(2)) + t.matches('E5117', fn.execute('messages')) + -- Running the (re-defined) mapping emits CmdAtom.keys with the updated id. + feed('dgt') + eq(' k4', fn.getline(2)) + n.exec_lua(([[vim.api.nvim_feedkeys(%q, 'nx', false)]]):format(atom_last().keys)) + eq('k4', fn.getline(2)) + end) + it('operator completed by a Lua textobject (:omap) #41482', function() -- Lua :omap textobject (starts Visual mode, |omap-info|) captures the operator: `keys` is the - -- redobuff (K_LUA + luaref), replayable like "." repeating it. `cmd` is omitted (a Lua - -- callback has no notation). + -- redobuff (K_LUA + mapping-id). n.exec_lua([[ vim.keymap.set('o', 'gt', function() vim.cmd('normal! viw')