From: Hirohito Higashi Date: Fri, 24 Jul 2026 20:04:21 +0000 (+0000) Subject: patch 9.2.0848: tagfunc "cmd" with a generic Ex command corrupts the tag entry X-Git-Tag: v9.2.0848^0 X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=86adef19fc2f1d59a38f3ba978ce33f0b4329c0f;p=thirdparty%2Fvim.git patch 9.2.0848: tagfunc "cmd" with a generic Ex command corrupts the tag entry Problem: When a tagfunc returns a "cmd" that is neither a line number nor a search pattern, the tag entry is corrupted: the "kind" field is lost and taglist() returns a mangled "cmd". Solution: Accept any Ex command in "cmd" as in a tags file, terminate a generic command with a bar so the trailing fields are preserved, and reject a value that cannot be stored in a tag line with E987 (Hirohito Higashi). fixes: #20781 related: #20790 closes: #20828 Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Mao-Yining Signed-off-by: Hirohito Higashi Signed-off-by: Christian Brabandt --- diff --git a/runtime/doc/tagsrch.txt b/runtime/doc/tagsrch.txt index e381d5fc08..2615cef9e2 100644 --- a/runtime/doc/tagsrch.txt +++ b/runtime/doc/tagsrch.txt @@ -1,4 +1,4 @@ -*tagsrch.txt* For Vim version 9.2. Last change: 2026 May 31 +*tagsrch.txt* For Vim version 9.2. Last change: 2026 Jul 24 VIM REFERENCE MANUAL by Bram Moolenaar @@ -936,7 +936,11 @@ include the following entries and each value must be a string: either relative to the current directory or a full path. cmd Ex command used to locate the tag in the file. This - can be either an Ex search pattern or a line number. + can be a line number, a search pattern or any other + Ex command, like in a tags file (see + |tags-file-format|). A search pattern must start and + end with "/" or "?". An invalid value results in + |E987|. Note that the format is similar to that of |taglist()|, which makes it possible to use its output to generate the result. The following fields are optional: diff --git a/src/tag.c b/src/tag.c index 96afc699b4..98b98897fc 100644 --- a/src/tag.c +++ b/src/tag.c @@ -1425,6 +1425,38 @@ prepare_pats(pat_T *pats, int has_re) } #ifdef FEAT_EVAL +// Classification of a "cmd" value returned by a tagfunc. +typedef enum { + TAGCMD_INVALID, // cannot be stored in a tag line + TAGCMD_ADDRESS, // line number or /pat/ or ?pat? (no "|" needed) + TAGCMD_GENERIC // any other Ex command: needs a "|" terminator +} tagcmd_T; + +/* + * Classify "cmd" from a tagfunc result (see tagcmd_T), like a tags file + * address, to decide whether a "|" terminator must be appended before storing + * it in a tag line. + */ + static tagcmd_T +tagfunc_cmd_kind(char_u *cmd) +{ + if (VIM_ISDIGIT(*cmd)) + return *skipdigits(cmd) == NUL ? TAGCMD_ADDRESS : TAGCMD_INVALID; + if (*cmd == '/' || *cmd == '?') + { + // A Tab inside the pattern is fine (Universal Ctags emits one for a + // tab-indented line); only trailing content after the closing + // delimiter, which would break the tag line fields, is rejected. + char_u *end = skip_regexp(cmd + 1, *cmd, FALSE); + + return (*end == *cmd && end[1] == NUL) + ? TAGCMD_ADDRESS : TAGCMD_INVALID; + } + if (vim_strpbrk(cmd, (char_u *)"\t\r\n") != NULL) + return TAGCMD_INVALID; + return TAGCMD_GENERIC; +} + /* * Call the user-defined function to generate a list of tags used by * find_tags(). @@ -1570,6 +1602,15 @@ find_tagfunc_tags( break; } + tagcmd_T cmdkind = tagfunc_cmd_kind(res_cmd); + if (cmdkind == TAGCMD_INVALID) + { + emsg(_(e_invalid_return_value_from_tagfunc)); + break; + } + if (cmdkind == TAGCMD_GENERIC) + len += 3; // need space for "|;\"" + if (name_only) mfp = vim_strsave(res_name); else @@ -1599,7 +1640,10 @@ find_tagfunc_tags( STRCPY(p, res_cmd); p += STRLEN(p); - if (has_extra) + if (cmdkind == TAGCMD_GENERIC) + *p++ = '|'; // terminate the command + + if (cmdkind == TAGCMD_GENERIC || has_extra) { STRCPY(p, ";\""); p += STRLEN(p); @@ -3781,7 +3825,12 @@ jumpto_tag( str++; if (find_extra(&str) == OK) { - pbuf_end = str; + // Drop a trailing "|" that terminates a generic Ex command, so it + // is not executed as an empty command separator. + if (str > pbuf && str[-1] == '|') + pbuf_end = str - 1; + else + pbuf_end = str; *pbuf_end = NUL; } } diff --git a/src/testdir/test_tagfunc.vim b/src/testdir/test_tagfunc.vim index 4ce9d2164b..4427839be8 100644 --- a/src/testdir/test_tagfunc.vim +++ b/src/testdir/test_tagfunc.vim @@ -456,4 +456,84 @@ func Test_tagfunc_deletes_lines() set tagfunc= endfunc +" The 'cmd' field can be a line number, a search pattern or any other Ex +" command, like in a tags file. A value that would break the tag line (an +" unterminated pattern, trailing junk, or a outside a pattern) is +" rejected with E987 instead of silently corrupting the result. +func Test_tagfunc_invalid_cmd() + func InvalidCmd(pat, flags, info) + return [{'name': 'a', 'filename': 'Xtest', 'cmd': a:pat, 'kind': 'f'}] + endfunc + set tagfunc=InvalidCmd + + " unterminated search pattern + call assert_fails("call taglist('/foo')", 'E987:') + " trailing content after the closing delimiter of a pattern + call assert_fails('call taglist("/foo/\tbar")', 'E987:') + " a line number with trailing junk + call assert_fails("call taglist('3G5')", 'E987:') + " a in a generic command would break the tag line fields + call assert_fails('call taglist("call\tcursor(3, 5)")', 'E987:') + + " valid forms are accepted and returned unchanged: line number, delimited + " patterns, and a Tab inside a pattern (Universal Ctags emits one like this + " for a tab-indented line) + call assert_equal('42', taglist('42')[0].cmd) + call assert_equal('/foo/', taglist('/foo/')[0].cmd) + call assert_equal('?foo?', taglist('?foo?')[0].cmd) + call assert_equal("/^\tint foo$/", taglist("/^\tint foo$/")[0].cmd) + + " a generic command round-trips with its kind field intact: before the fix + " it swallowed the ';"kind' part, so kind came back empty (#20781) + let t = taglist('call cursor(3, 5)')[0] + call assert_equal('call cursor(3, 5)', t.cmd) + call assert_equal('f', t.kind) + + set tagfunc& + delfunc InvalidCmd +endfunc + +" A tagfunc 'cmd' is executed on a tag jump. A search pattern must reach the +" matched column (no regression from the do_search() path), and a line number +" or a generic Ex command must position the cursor as written. +func Test_tagfunc_cmd_jump() + call writefile(['line one', 'line two', 'foo bar baz'], 'Xtagcmd', 'D') + func JumpCmd(pat, flags, info) + return [{'name': 'a', 'filename': 'Xtagcmd', 'cmd': s:cmd, 'kind': 'f'}] + endfunc + set tagfunc=JumpCmd + + " '5|' / cursor col 5 / the 'b' of 'bar' all land on [3, 5] + for cmd in ['/bar/', 'call cursor(3, 5)', 'normal! 3G5|'] + let s:cmd = cmd + enew + tag a + call assert_equal([3, 5], [line('.'), col('.')], 'cmd: ' .. cmd) + bw! + endfor + + set tagfunc& + delfunc JumpCmd +endfunc + +" A generic Ex command from a tagfunc runs under 'secure' and the sandbox, just +" like one from a tags file, so it cannot delete a file or run a shell. +func Test_tagfunc_cmd_secure() + call writefile(['one', 'two', 'three'], 'Xtagcmd', 'D') + call writefile(['keep me'], 'Xvictim', 'D') + func EvilTagFunc(pat, flags, info) + return [{'name': 'a', 'filename': 'Xtagcmd', + \ 'cmd': "call delete('Xvictim')", 'kind': 'f'}] + endfunc + set tagfunc=EvilTagFunc + + enew + call assert_fails('tag a', 'E12:') + call assert_true(filereadable('Xvictim')) + + bw! + set tagfunc& + delfunc EvilTagFunc +endfunc + " vim: shiftwidth=2 sts=2 expandtab diff --git a/src/version.c b/src/version.c index a0b96e2ebb..2cf1ab9425 100644 --- a/src/version.c +++ b/src/version.c @@ -758,6 +758,8 @@ static char *(features[]) = static int included_patches[] = { /* Add new patch number below this line */ +/**/ + 848, /**/ 847, /**/