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 (`<K_LUA><ref><CR>`). 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 `<K_LUA><id><CR>`.

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: 5ac2e47acc
This commit is contained in:
Justin M. Keyes committed 2026-08-31 00:16:01 +02:00
1 parent bc16be3cd9
commit 25f7c87a70
9 files changed
+93 -21

No files matched your search

+6
View File
@@ -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*
+4 -4
View File
@@ -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
+13 -6
View File
@@ -196,6 +196,7 @@ static const char e_cmd_mapping_must_end_with_cr[]
= N_("E1255: <Cmd> mapping must end with <CR>");
static const char e_cmd_mapping_must_end_with_cr_before_second_cmd[]
= N_("E1136: <Cmd> mapping must end with <CR> before second <Cmd>");
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;
+2 -1
View File
@@ -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) {
+1
View File
@@ -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"
+35 -6
View File
@@ -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 "<K_LUA><id><CR>".
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 <lua>ref_no<cr> in map_str
// Stores "<K_LUA><lua-mapping-id><CR>" 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);
}
}
+3
View File
@@ -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,
-2
View File
@@ -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 "."
+29 -2
View File
@@ -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')