From e63cf571067e8d1777adc354b098dfc91c2ab5de Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 24 Jul 2026 16:28:16 -0700 Subject: [PATCH 1/6] fix(zip): keep a leading dash from becoming a backend option --- runtime/lua/nvim/zip.lua | 9 ++++++++- test/functional/plugin/zip_spec.lua | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua index 7dd55b90dc..af07794039 100644 --- a/runtime/lua/nvim/zip.lua +++ b/runtime/lua/nvim/zip.lua @@ -18,7 +18,14 @@ end ---@param value string ---@return string local function literal_pattern(value) - return (value:gsub('\\', '\\\\'):gsub('%?', '\\?'):gsub('%*', '\\*'):gsub('%[', '[[]')) + return ( + value + :gsub('\\', '\\\\') + :gsub('%?', '\\?') + :gsub('%*', '\\*') + :gsub('%[', '[[]') + :gsub('^%-', '[-]') + ) end ---@param source string diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua index 7d5497710a..5ec4329283 100644 --- a/test/functional/plugin/zip_spec.lua +++ b/test/functional/plugin/zip_spec.lua @@ -271,6 +271,18 @@ describe('nvim.zip', function() end end) + it('keeps a leading dash in a member from becoming a backend option', function() + local archive = vim.fs.joinpath(root, 'poc.zip') + copy_fixture(vim.fs.joinpath(old_samples, 'poc.zip'), archive) + clear_zip() + + edit(archive) + eq({ '-d/', 'pwned' }, lines()) + + edit(('zipfile://%s::-d/tmp'):format(archive)) + eq({ '' }, lines()) + end) + it('treats archive glob characters literally', function() t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') local archive = vim.fs.joinpath(root, 'archive::[*?].zip') From f82cf8fd596fa173a028bd04035d0465d09f8d48 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 24 Jul 2026 16:28:25 -0700 Subject: [PATCH 2/6] fix(zip): list a valid empty archive as empty --- runtime/lua/nvim/zip.lua | 3 +++ test/functional/plugin/zip_spec.lua | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua index af07794039..7264d33558 100644 --- a/runtime/lua/nvim/zip.lua +++ b/runtime/lua/nvim/zip.lua @@ -45,6 +45,9 @@ local function list_archive(source) end local result = system:wait() if result.code ~= 0 then + if vim.trim(result.stdout or '') == 'Empty zipfile.' then + return {} + end return nil, vim.trim(result.stderr or ''), true end return vim.split(result.stdout or '', '\n', { plain = true, trimempty = true }) diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua index 5ec4329283..f849f19a68 100644 --- a/test/functional/plugin/zip_spec.lua +++ b/test/functional/plugin/zip_spec.lua @@ -283,6 +283,20 @@ describe('nvim.zip', function() eq({ '' }, lines()) end) + it('lists a valid empty archive as empty', function() + local archive = vim.fs.joinpath(root, 'empty.zip') + local file = assert(io.open(archive, 'wb')) + file:write('PK\5\6' .. string.rep('\0', 18)) + file:close() + clear_zip() + + edit(archive) + + eq({ '' }, lines()) + eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) + eq(true, exec_lua('return vim.b.nvim_zip ~= nil')) + end) + it('treats archive glob characters literally', function() t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') local archive = vim.fs.joinpath(root, 'archive::[*?].zip') From 36204b4323378a32df628a0893b8b1c40bac3b39 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 24 Jul 2026 16:28:36 -0700 Subject: [PATCH 3/6] feat(zip): extract archive members --- runtime/doc/zip.txt | 16 +++--- runtime/lua/nvim/zip.lua | 55 +++++++++++++++++++++ runtime/plugin/zip.lua | 4 ++ test/functional/plugin/zip_spec.lua | 77 ++++++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 7 deletions(-) diff --git a/runtime/doc/zip.txt b/runtime/doc/zip.txt index caf20cc236..4dae097e68 100644 --- a/runtime/doc/zip.txt +++ b/runtime/doc/zip.txt @@ -22,8 +22,12 @@ Editing a zip-like file opens a read-only listing. Directory entries can be opened to browse that level of the archive. File entries open as read-only `zipfile://{archive}::{path}` buffers. -Mappings are the standard |dir-buffer-mappings|. At the archive root, - opens -the containing filesystem directory. +Mappings are the standard |dir-buffer-mappings|, plus: + +• x extracts the entry under the cursor into the current directory, discarding + its path inside the archive. It never overwrites an existing file. + +At the archive root, - opens the containing filesystem directory. Recognized extensions include zip, jar, apk, epub, Office and OpenDocument formats, whl, xpi, pkpass, and cbz. @@ -36,10 +40,10 @@ Remote zip URLs are downloaded by |vim.net| and opened in the browser. LIMITATIONS *zip-limitations* -Archive entries cannot be updated or extracted. Remote archives are not -refetched on reload, and there is no PowerShell fallback, no custom backend -command, and no support for the legacy `g:zip_*` variables. Use |old-zip| -when those features are required. +Archive entries cannot be updated. Remote archives are not refetched on +reload, and there is no PowerShell fallback, no custom backend command, and no +support for the legacy `g:zip_*` variables. Use |old-zip| when those features +are required. ============================================================================== Legacy plugin: zip *old-zip* diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua index 7264d33558..7b3c2e000c 100644 --- a/runtime/lua/nvim/zip.lua +++ b/runtime/lua/nvim/zip.lua @@ -398,9 +398,64 @@ function M.open_parent(buf, name) require('nvim.dir.fs').open_parent_path(state.source) end +--- Extract the entry under the cursor into the current directory. +--- `-j` discards the archive path, so the destination is always a name in the +--- current directory and cannot be redirected by a hostile entry. +function M._extract() + local buf = api.nvim_get_current_buf() + local state = get_state(buf) + if not state then + return + end + local command, command_err = unzip() + if not command then + notify('zip', command_err or 'unzip executable not found') + return + end + local line = api.nvim_get_current_line() + if line == '' then + return + end + if line:sub(-1) == '/' then + notify('zip', 'please specify a file, not a directory') + return + end + local path = (state.prefix or '') .. line + local directory = vim.fn.getcwd() + local target = vim.fs.joinpath(directory, vim.fs.basename(path)) + if uv.fs_stat(target) then + notify('zip', ('%s already exists, not overwriting'):format(target)) + return + end + local ok, system = pcall(vim.system, { + command, + '-o', + '-j', + '--', + literal_pattern(state.source), + literal_pattern(path), + }, { cwd = directory, text = true }) + if not ok then + notify('zip', tostring(system)) + return + end + local result = system:wait() + if not uv.fs_stat(target) then + notify( + 'zip', + ('unable to extract %s from %s: %s'):format(path, state.source, vim.trim(result.stderr or '')) + ) + return + end + notify('zip', ('extracted %s'):format(target), vim.log.levels.INFO) +end + ---@param buf integer function M.init(buf) api.nvim_set_option_value('filetype', 'zip', { buf = buf }) + if vim.fn.hasmapto('(nvim-zip-extract)', 'n') == 0 then + vim.keymap.set('n', 'x', '(nvim-zip-extract)', { buffer = buf, silent = true }) + end api.nvim_buf_call(buf, function() vim.wo.wrap = false end) diff --git a/runtime/plugin/zip.lua b/runtime/plugin/zip.lua index b43de878e2..8ba5aaa6c6 100644 --- a/runtime/plugin/zip.lua +++ b/runtime/plugin/zip.lua @@ -73,6 +73,10 @@ local archive_patterns = {} for _, extension in ipairs(extensions) do archive_patterns[#archive_patterns + 1] = ('*.%s'):format(extension) end +vim.keymap.set('n', '(nvim-zip-extract)', function() + require('nvim.zip')._extract() +end, { silent = true, desc = 'Extract zip archive entry' }) + local group = api.nvim_create_augroup('nvim.zip', { clear = true }) ---@return boolean diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua index f849f19a68..1b2023083b 100644 --- a/test/functional/plugin/zip_spec.lua +++ b/test/functional/plugin/zip_spec.lua @@ -271,7 +271,7 @@ describe('nvim.zip', function() end end) - it('keeps a leading dash in a member from becoming a backend option', function() + it('keeps a leading dash in a path from becoming a backend option', function() local archive = vim.fs.joinpath(root, 'poc.zip') copy_fixture(vim.fs.joinpath(old_samples, 'poc.zip'), archive) clear_zip() @@ -363,6 +363,81 @@ describe('nvim.zip', function() eq({ 'nested/', 'root.txt', 'root.java' }, lines()) end) + it('extracts the entry under the cursor into the current directory', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + api.nvim_set_current_dir(root) + + edit(archive) + feed('') + poke_eventloop() + api.nvim_win_set_cursor(0, { line_of('root.java'), 0 }) + feed('x') + poke_eventloop() + + eq('class root {}\n', t.read_file(vim.fs.joinpath(root, 'root.java'))) + end) + + it('refuses to extract a directory', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + api.nvim_set_current_dir(root) + + edit(archive) + api.nvim_win_set_cursor(0, { line_of('folder/'), 0 }) + feed('x') + poke_eventloop() + + eq(true, exec_capture('messages'):find('not a directory', 1, true) ~= nil) + eq(nil, vim.uv.fs_stat(vim.fs.joinpath(root, 'folder'))) + end) + + it('refuses to overwrite an existing file when extracting', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + local target = vim.fs.joinpath(root, 'root.java') + t.write_file(target, 'untouched', true) + clear_zip() + api.nvim_set_current_dir(root) + + edit(archive) + feed('') + poke_eventloop() + api.nvim_win_set_cursor(0, { line_of('root.java'), 0 }) + feed('x') + poke_eventloop() + + eq(true, exec_capture('messages'):find('already exists', 1, true) ~= nil) + eq('untouched', t.read_file(target)) + end) + + it('extracts suspicious entries without escaping the current directory', function() + local archive = vim.fs.joinpath(root, 'evil.zip') + copy_fixture(vim.fs.joinpath(old_samples, 'evil.zip'), archive) + clear_zip() + api.nvim_set_current_dir(root) + + edit(archive) + eq({ + '../../../../etc/ax-pwn', + 'a/../../../../../../../../../../../../../../../../../../tmp/foobar', + '/tmp/vim_zip/a/b/payload.txt', + }, lines()) + + for _, entry in ipairs(lines()) do + api.nvim_win_set_cursor(0, { line_of(entry), 0 }) + feed('x') + poke_eventloop() + end + + for _, name in ipairs({ 'ax-pwn', 'foobar', 'payload.txt' }) do + eq(true, vim.uv.fs_stat(vim.fs.joinpath(root, name)) ~= nil) + end + eq(nil, vim.uv.fs_stat(vim.fs.joinpath(root, '..', 'ax-pwn'))) + end) + it('reports an unavailable backend without claiming the buffer', function() local archive = vim.fs.joinpath(root, 'browser.zip') copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) From 1ffbd3598abafd0cc63fe6e4c5c6346cd5a1b784 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 24 Jul 2026 16:54:49 -0700 Subject: [PATCH 4/6] test(zip): cover difficult member names --- test/functional/fixtures/zip/browser.zip | Bin 1143 -> 4084 bytes test/functional/plugin/zip_spec.lua | 60 +++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/test/functional/fixtures/zip/browser.zip b/test/functional/fixtures/zip/browser.zip index 24e271642219e06a04dbc3ec23ab9fb0e501e2de..58288753e6088be43f07ee7df091221772ee9825 100644 GIT binary patch literal 4084 zcmWIWW@Zs#fB;1Z1tr-T7#Py>b5c@^^l>ZVLnz8iEiOq-K~tN;IK_&Gfq?;p#W2*S zW#**nl~j~Kv?`ROR+Mm|+SMwXRLaG`zyQMh2>nI*`6VEYAR3#t7>PDPka7^_WME*( z%*#tH(yPiWfZ9}$SecWbn1W{6&u@1fK{`N~mw|ynPftI!I61KZ>_!Mn0lUsa%e0pZ zGB7ZJFi590|NsHgZ!44pPHWo(vX+0kd~R3n4^%FTAq`cmx>6<1#RDB%KGD_K(PhFAg$1- z1%;qqR$^IVa!z7#u>y#zP+beRl7r!P>yH>vieLf}3=9lx3=9l;iMgr8`pgV48z8ET z3ojm&VPIg8VqjnZB`Jui;)2BFR0R+lh;eE#Us`g5a zhfHXunZ6Gwm1SUHkY-?D5QW=WQl77%05-B1-8jwj;Q?SXb+2JF?`zOPHjv9C7#J9Y z;N~ghq$Z|h=B1+<7I4DT*XQIJ@9Wr1I#<{|ALJ@Y1_lOUxJe~NiJ3W>dFcvhMrmkz zojG&zjCc42Y-TC#*I>mAv!ul2?3m)5#NrGz!#uov&h=@Y^VB(q)im|>iHGGF7#Kh% zf^r)u)Qk0tGxO4OQq>Df^C9UGIS$U9@jmYvs;_fa-xF&rWZwupfoWn&erZxpsuDI6 zJx=QTp7IRU!fE1(SB;M_<25BeCnvE;1>|6KFP}Vr(o-ksB(8Mg#jaT)&%nR{GE*EL z8=w#@$xO~pz-FSC&pEx5KIi;!ro9saA=;QRT%4MlX^q7^4ZpKzx17V}z9;WoG%?L8 z$Sg>$!DiM89gnp*{Tj((oPwE#6LSmHuo&fWMmOLzE?0dsP}>R$F_6m;xvLc%8ir4zMfO((z<}6IPH}sCgnOF)v*aWE`4l9$sg>!_VMy+r!wMmoUxB zNG#4!#$wh951n%XI75HsE_p{#ZUA9KjwnbiN>0ryQN?1MhKH^fn^EXzq2l1LURqu| z&`PD({IRn@sSFfmplSkSLa}~vNn()}njbavJasNz!WLFny3TLI^j~3VYH>+sex5zL zIXb?&dMD4E-;A{^N-|Or!Za&7sVFfyJGCSh&8!YTEia#Qe!87l^ZKUf<)xT@(@nuF zhA#Lbmq3h6BFv!j9#lbKHX%Twpvvm6BZvj9bppI`YXVhJAf*sZs)$M-Qg`d4>qT#3 zK=d*&G%yBY=!G>n(9J+^W+2R%h%f_GH-Z8bT{C)P1EKi@a?=A{8+vO4q3s`f+XG!I zdUFGz)q;_M0j2SQt`)ttfzVpQh-iDD>q2jHAapHZWMF`|K#;XzYjYs9-DZS0F2TVH zYJnhhL)sG{)yS<55Fe5##F=1i5Oh80Z4ihaP-3u!>j5_(;4Kn#6VTfo2ot*DCZM%K z(2YQEY9Nfb4L1V0;eoCny@7$yugVPbI7)K^-2n7f1;T(rxB;LV64Ay$HvzpRfiPh| z+ywM?1-cpNZ3l!If-Ep^ptmH@%|LH0Ak0WcG6SXUfNlhO0|8;gX1Eccz(8p(pc{hT zB0w0z&IVI@2 z&?|X_5teMQKtQST(G5Va*%1b`!VN&F=g|#7ug(z$T!b5dUb&;2fnJ3p%ur;9`2t>{ zqiaU5yAhgG;hIrua&!aGD{O=T8{r0^RNv?Zpx4p}1OCDdfY;dp-mIYh8iPE8Ji}ae L1_nn^2a^E+N)GLQ delta 33 mcmew&|D9ulB-3VR4lgDVRyGC(1_mw$E(R|q1_p5^5Dx%=g9N|; diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua index 1b2023083b..5e969bc4f9 100644 --- a/test/functional/plugin/zip_spec.lua +++ b/test/functional/plugin/zip_spec.lua @@ -136,10 +136,15 @@ describe('nvim.zip', function() edit(archive) - eq( - { 'folder/', 'inner.zip', '../escape.txt', '/absolute.txt', 'crlf.txt', 'noeol.txt' }, - lines() - ) + eq({ + 'folder/', + 'inner.zip', + '../escape.txt', + '/absolute.txt', + 'crlf.txt', + 'noeol.txt', + 'names/', + }, lines()) eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) eq(true, exec_capture('syntax list zipDirectory'):find('zipDirectory', 1, true) ~= nil) @@ -271,6 +276,53 @@ describe('nvim.zip', function() end end) + it('lists and opens entries with difficult names', function() + t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + local names = { + 'space name.txt', + 'two spaces.txt', + ' leading.txt', + 'trailing .txt', + [[back\slash.txt]], + [[single'quote.txt]], + 'double"quote.txt', + 'dollar$name.txt', + 'backtick`name.txt', + 'semi;name.txt', + 'pipe|name.txt', + 'amp&name.txt', + 'paren(name).txt', + 'bang!name.txt', + 'hash#name.txt', + 'percent%name.txt', + 'star*.txt', + 'question?.txt', + '[bracket].txt', + '-dash.txt', + } + + edit(archive) + api.nvim_win_set_cursor(0, { line_of('names/'), 0 }) + feed('') + poke_eventloop() + eq(names, lines()) + + api.nvim_win_set_cursor(0, { line_of('star*.txt'), 0 }) + feed('') + poke_eventloop() + eq(('zipfile://%s::names/star*.txt'):format(archive), api.nvim_buf_get_name(0)) + eq({ 'content of star*.txt' }, lines()) + + for _, name in ipairs(names) do + edit(('zipfile://%s::names/%s'):format(archive, name)) + eq({ 'content of ' .. name }, lines()) + end + end) + it('keeps a leading dash in a path from becoming a backend option', function() local archive = vim.fs.joinpath(root, 'poc.zip') copy_fixture(vim.fs.joinpath(old_samples, 'poc.zip'), archive) From 1bba96f259b53ff09d40f18949737ba049366eaa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 26 Jul 2026 11:26:50 -0500 Subject: [PATCH 5/6] fix(test): group zip tests and drop repeated setup --- test/functional/plugin/zip_spec.lua | 836 ++++++++++++++-------------- 1 file changed, 406 insertions(+), 430 deletions(-) diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua index 5e969bc4f9..407f0766e3 100644 --- a/test/functional/plugin/zip_spec.lua +++ b/test/functional/plugin/zip_spec.lua @@ -25,10 +25,6 @@ local function clear_zip() n.clear({ args = { '--clean' } }) end -local function copy_fixture(source, target) - assert(vim.uv.fs_copyfile(source, target)) -end - local function line_of(text) for i, line in ipairs(lines()) do if line == text then @@ -41,6 +37,21 @@ end describe('nvim.zip', function() local root + --- Copy an archive into the test directory and return its path. + local function stage(source_dir, source, as) + local target = vim.fs.joinpath(root, as or source) + assert(vim.uv.fs_copyfile(vim.fs.joinpath(source_dir, source), target)) + return target + end + + --- Stage the browsing fixture, restart, and open it. + local function browse(as) + local archive = stage(fixtures, 'browser.zip', as) + clear_zip() + edit(archive) + return archive + end + before_each(function() t.skip(vim.fn.executable('unzip') == 0, 'unzip not available') root = vim.fs.normalize(t.tmpname(false) .. ' space%#') @@ -51,455 +62,420 @@ describe('nvim.zip', function() n.rmdir(root) end) - it('uses zip.lua by default', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() + describe('activation', function() + it('uses zip.lua by default', function() + browse() - edit(archive) + eq('folder/', lines()[1]) + eq(true, exec_lua('return vim.g.loaded_nvim_zip_plugin == true')) + eq(false, exec_lua('return vim.g.loaded_zipPlugin ~= nil')) + end) - eq('folder/', lines()[1]) - eq(true, exec_lua('return vim.g.loaded_nvim_zip_plugin == true')) - eq(false, exec_lua('return vim.g.loaded_zipPlugin ~= nil')) - end) + it('defers to zipPlugin.vim loaded before startup plugins', function() + local archive = stage(old_samples, 'test.zip', 'legacy.zip') + n.clear({ args = { '--clean', '--cmd', 'packadd old-zip' } }) - it('defers to zipPlugin.vim loaded before startup plugins', function() - local archive = vim.fs.joinpath(root, 'legacy.zip') - copy_fixture(vim.fs.joinpath(old_samples, 'test.zip'), archive) - n.clear({ args = { '--clean', '--cmd', 'packadd old-zip' } }) + edit(archive) - edit(archive) + eq(true, lines()[1]:find('" zip.vim version', 1, true) ~= nil) + eq(0, fn.exists('#nvim.zip')) + eq(false, exec_lua('return vim.g.loaded_nvim_zip_plugin ~= nil')) + end) - eq(true, lines()[1]:find('" zip.vim version', 1, true) ~= nil) - eq(0, fn.exists('#nvim.zip')) - eq(false, exec_lua('return vim.g.loaded_nvim_zip_plugin ~= nil')) - end) + it('yields to zipPlugin.vim loaded after startup', function() + local archive = stage(old_samples, 'test.zip', 'legacy.zip') + clear_zip() + exec_lua([[vim.cmd.packadd('old-zip')]]) - it('yields to zipPlugin.vim loaded after startup', function() - local archive = vim.fs.joinpath(root, 'legacy.zip') - copy_fixture(vim.fs.joinpath(old_samples, 'test.zip'), archive) - clear_zip() - exec_lua([[vim.cmd.packadd('old-zip')]]) + edit(archive) - edit(archive) + eq(true, lines()[1]:find('" zip.vim version', 1, true) ~= nil) + eq(1, fn.exists('#nvim.zip')) + eq(true, exec_lua('return vim.g.loaded_nvim_zip_plugin == true')) + end) - eq(true, lines()[1]:find('" zip.vim version', 1, true) ~= nil) - eq(1, fn.exists('#nvim.zip')) - eq(true, exec_lua('return vim.g.loaded_nvim_zip_plugin == true')) - end) + it('can be disabled', function() + local archive = stage(fixtures, 'browser.zip') + n.clear({ + args = { '--clean', '--cmd', 'let g:loaded_nvim_zip_plugin = 1' }, + }) - it('can be disabled', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - n.clear({ - args = { '--clean', '--cmd', 'let g:loaded_nvim_zip_plugin = 1' }, - }) + edit(archive) - edit(archive) + eq(0, fn.exists('#nvim.zip')) + eq('', api.nvim_get_option_value('filetype', { buf = 0 })) + end) - eq(0, fn.exists('#nvim.zip')) - eq('', api.nvim_get_option_value('filetype', { buf = 0 })) - end) + it('can source zip.lua repeatedly', function() + clear_zip() - it('can source zip.lua repeatedly', function() - clear_zip() + -- Re-sourcing must reuse the augroup rather than stack duplicate autocmds. + eq( + 2, + exec_lua(function() + vim.cmd.runtime('plugin/zip.lua') + vim.cmd.runtime('plugin/zip.lua') + local ids = {} + for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ group = 'nvim.zip' })) do + ids[autocmd.id] = true + end + return vim.tbl_count(ids) + end) + ) + end) - eq( - 2, - exec_lua(function() - vim.cmd.runtime('plugin/zip.lua') - vim.cmd.runtime('plugin/zip.lua') - local ids = {} - for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ group = 'nvim.zip' })) do - ids[autocmd.id] = true - end - return vim.tbl_count(ids) - end) - ) - end) + it('reports an unavailable backend without claiming the buffer', function() + local archive = stage(fixtures, 'browser.zip') + clear_zip() + exec_lua([[vim.env.PATH = '']]) - it('opens zip-compatible file types', function() - local archive = vim.fs.joinpath(root, 'browser.jar') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(archive) - - eq('folder/', lines()[1]) - eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) - end) - - it('browses directories and opens entries', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(archive) - - eq({ - 'folder/', - 'inner.zip', - '../escape.txt', - '/absolute.txt', - 'crlf.txt', - 'noeol.txt', - 'names/', - }, lines()) - eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) - eq(true, exec_capture('syntax list zipDirectory'):find('zipDirectory', 1, true) ~= nil) - - feed('') - poke_eventloop() - eq({ 'nested/', 'root.txt', 'root.java' }, lines()) - - feed('') - poke_eventloop() - eq({ 'file.txt' }, lines()) - - feed('-') - poke_eventloop() - eq({ 'nested/', 'root.txt', 'root.java' }, lines()) - eq('nested/', api.nvim_get_current_line()) - - api.nvim_win_set_cursor(0, { 2, 0 }) - feed('') - poke_eventloop() - eq({ 'root text' }, lines()) - eq('nowrite', api.nvim_get_option_value('buftype', { buf = 0 })) - eq(true, api.nvim_get_option_value('readonly', { buf = 0 })) - eq(false, api.nvim_get_option_value('modifiable', { buf = 0 })) - eq(false, api.nvim_get_option_value('swapfile', { buf = 0 })) - end) - - it('opens the containing directory from the archive root', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(archive) - feed('-') - poke_eventloop() - - eq(root, vim.fs.normalize(api.nvim_buf_get_name(0))) - eq('browser.zip', api.nvim_get_current_line()) - eq('directory', api.nvim_get_option_value('filetype', { buf = 0 })) - end) - - it('opens entries at quickfix locations', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - local uri = ('zipfile://%s::crlf.txt'):format(archive) - fn.setqflist({}, 'r', { - items = { { filename = uri, lnum = 2, col = 1 } }, - }) - - api.nvim_cmd({ cmd = 'cfirst' }, {}) - - eq(uri, api.nvim_buf_get_name(0)) - eq({ 2, 0 }, api.nvim_win_get_cursor(0)) - eq('text', api.nvim_get_option_value('filetype', { buf = 0 })) - end) - - it('does not reinterpret an entry ending in .zip as an archive', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(archive) - api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 }) - feed('') - poke_eventloop() - - eq({ 'nested payload' }, lines()) - eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) - end) - - it('preserves normal file reading details for entries', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(('zipfile://%s::crlf.txt'):format(archive)) - eq({ 'one', 'two' }, lines()) - eq('dos', api.nvim_get_option_value('fileformat', { buf = 0 })) - eq(true, api.nvim_get_option_value('endofline', { buf = 0 })) - - edit(('zipfile://%s::noeol.txt'):format(archive)) - eq({ 'no final newline' }, lines()) - eq(false, api.nvim_get_option_value('endofline', { buf = 0 })) - end) - - it('integrates Java sources with zip.lua and zipPlugin.vim', function() - local archive = vim.fs.joinpath(root, 'source.jar') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - - for _, legacy in ipairs({ false, true }) do - if legacy then - n.clear({ - args = { '--clean', '--cmd', 'packadd old-zip' }, - }) - else - clear_zip() - end - exec_lua('vim.g.ftplugin_java_source_path = ...', archive) - api.nvim_buf_set_name(0, vim.fs.joinpath(root, 'Test.java')) - api.nvim_buf_set_lines(0, 0, -1, false, { 'folder.root' }) - api.nvim_cmd({ cmd = 'setfiletype', args = { 'java' } }, {}) - api.nvim_cmd({ cmd = 'runtime', args = { 'ftplugin/java.vim' } }, {}) - - feed('gf') + edit(archive) poke_eventloop() - eq(('zipfile://%s::folder/root.java'):format(archive), api.nvim_buf_get_name(0)) - eq({ 'class root {}' }, lines()) - eq('java', api.nvim_get_option_value('filetype', { buf = 0 })) + eq(true, exec_capture('messages'):find('unzip executable not found', 1, true) ~= nil) + eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + end) + end) + + describe('browsing', function() + it('opens zip-compatible file types', function() + browse('browser.jar') + + eq('folder/', lines()[1]) + eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('browses directories and opens entries', function() + browse() + + eq({ + 'folder/', + 'inner.zip', + '../escape.txt', + '/absolute.txt', + 'crlf.txt', + 'noeol.txt', + 'names/', + }, lines()) + eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) + eq(true, exec_capture('syntax list zipDirectory'):find('zipDirectory', 1, true) ~= nil) + + feed('') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + + feed('') + poke_eventloop() + eq({ 'file.txt' }, lines()) + + feed('-') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + eq('nested/', api.nvim_get_current_line()) + + api.nvim_win_set_cursor(0, { 2, 0 }) + feed('') + poke_eventloop() + eq({ 'root text' }, lines()) + eq('nowrite', api.nvim_get_option_value('buftype', { buf = 0 })) + eq(true, api.nvim_get_option_value('readonly', { buf = 0 })) + eq(false, api.nvim_get_option_value('modifiable', { buf = 0 })) + eq(false, api.nvim_get_option_value('swapfile', { buf = 0 })) + end) + + it('opens the containing directory from the archive root', function() + browse() + feed('-') + poke_eventloop() + + eq(root, vim.fs.normalize(api.nvim_buf_get_name(0))) + eq('browser.zip', api.nvim_get_current_line()) + eq('directory', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('opens entries at quickfix locations', function() + local archive = stage(fixtures, 'browser.zip') + clear_zip() + local uri = ('zipfile://%s::crlf.txt'):format(archive) + fn.setqflist({}, 'r', { items = { { filename = uri, lnum = 2, col = 1 } } }) + + api.nvim_cmd({ cmd = 'cfirst' }, {}) + + eq(uri, api.nvim_buf_get_name(0)) + eq({ 2, 0 }, api.nvim_win_get_cursor(0)) + eq('text', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('does not reinterpret an entry ending in .zip as an archive', function() + browse() + api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 }) + feed('') + poke_eventloop() + + eq({ 'nested payload' }, lines()) + eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + end) + + it('preserves normal file reading details for entries', function() + local archive = stage(fixtures, 'browser.zip') + clear_zip() + + edit(('zipfile://%s::crlf.txt'):format(archive)) + eq({ 'one', 'two' }, lines()) + eq('dos', api.nvim_get_option_value('fileformat', { buf = 0 })) + eq(true, api.nvim_get_option_value('endofline', { buf = 0 })) + + edit(('zipfile://%s::noeol.txt'):format(archive)) + eq({ 'no final newline' }, lines()) + eq(false, api.nvim_get_option_value('endofline', { buf = 0 })) + end) + + it('keeps suspicious entry paths visible and readable', function() + browse() + api.nvim_win_set_cursor(0, { line_of('../escape.txt'), 0 }) + feed('') + poke_eventloop() + + eq({ 'escape payload' }, lines()) + end) + + it('opens non-zip files normally', function() + local archive = vim.fs.joinpath(root, 'plain.zip') + t.write_file(archive, 'plain text', true) + clear_zip() + + edit(archive) + + eq({ 'plain text' }, lines()) + eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + end) + + it('lists a valid empty archive as empty', function() + local archive = vim.fs.joinpath(root, 'empty.zip') + local file = assert(io.open(archive, 'wb')) + -- An end-of-central-directory record with no entries: a valid, empty archive. + file:write('PK\5\6' .. string.rep('\0', 18)) + file:close() + clear_zip() + + edit(archive) + + eq({ '' }, lines()) + eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) + eq(true, exec_lua('return vim.b.nvim_zip ~= nil')) + end) + + it('keeps the current level when reload fails', function() + local archive = browse() + feed('') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + + assert(os.remove(archive)) + feed('R') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + + stage(fixtures, 'browser.zip') + feed('R') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + end) + + it('integrates Java sources with zip.lua and zipPlugin.vim', function() + local archive = stage(fixtures, 'browser.zip', 'source.jar') + + for _, legacy in ipairs({ false, true }) do + if legacy then + n.clear({ args = { '--clean', '--cmd', 'packadd old-zip' } }) + else + clear_zip() + end + exec_lua('vim.g.ftplugin_java_source_path = ...', archive) + api.nvim_buf_set_name(0, vim.fs.joinpath(root, 'Test.java')) + api.nvim_buf_set_lines(0, 0, -1, false, { 'folder.root' }) + api.nvim_cmd({ cmd = 'setfiletype', args = { 'java' } }, {}) + api.nvim_cmd({ cmd = 'runtime', args = { 'ftplugin/java.vim' } }, {}) + + feed('gf') + poke_eventloop() + + eq(('zipfile://%s::folder/root.java'):format(archive), api.nvim_buf_get_name(0)) + eq({ 'class root {}' }, lines()) + eq('java', api.nvim_get_option_value('filetype', { buf = 0 })) + end + end) + end) + + -- Info-ZIP expands globs itself and reads leading dashes as options, so archive + -- and entry paths must always reach it as literal text. + describe('backend arguments', function() + it('reads entry selectors literally', function() + t.skip(t.is_os('win'), 'N/A: archive contains backslashes in entry paths') + local archive = stage(old_samples, 'testa.zip', 'special.zip') + clear_zip() + + local cases = { + { 'zipglob/a[a].txt', 'a test file with []' }, + { 'zipglob/a*.txt', 'a test file with a*' }, + { 'zipglob/a?.txt', 'a test file with a?' }, + { [[zipglob/a\.txt]], [[a test file with a\]] }, + { [[zipglob/a\\.txt]], [[a test file with a double \]] }, + } + for _, case in ipairs(cases) do + edit(('zipfile://%s::%s'):format(archive, case[1])) + eq({ case[2] }, lines()) + end + end) + + it('keeps a leading dash in a path from becoming a backend option', function() + local archive = stage(old_samples, 'poc.zip') + clear_zip() + + edit(archive) + eq({ '-d/', 'pwned' }, lines()) + + edit(('zipfile://%s::-d/tmp'):format(archive)) + eq({ '' }, lines()) + end) + + it('treats archive glob characters literally', function() + t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') + local archive = stage(fixtures, 'browser.zip', 'archive::[*?].zip') + -- Would be matched instead of the archive above if the name were globbed. + stage(old_samples, 'test.zip', 'archivex.zip') + clear_zip() + + edit(archive) + + eq('folder/', lines()[1]) + api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 }) + feed('') + poke_eventloop() + eq({ 'nested payload' }, lines()) + + local uri = ('zipfile://%s::crlf.txt'):format(archive) + edit(uri) + eq(uri, api.nvim_buf_get_name(0)) + eq({ 'one', 'two' }, lines()) + end) + + it('lists and opens entries with difficult names', function() + t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') + local archive = browse() + + -- "star*" and "-dash" are the glob and option cases; the rest are shell + -- metacharacters that must survive an argv-based backend untouched. + local names = { + 'space name.txt', + 'two spaces.txt', + ' leading.txt', + 'trailing .txt', + [[back\slash.txt]], + [[single'quote.txt]], + 'double"quote.txt', + 'dollar$name.txt', + 'backtick`name.txt', + 'semi;name.txt', + 'pipe|name.txt', + 'amp&name.txt', + 'paren(name).txt', + 'bang!name.txt', + 'hash#name.txt', + 'percent%name.txt', + 'star*.txt', + 'question?.txt', + '[bracket].txt', + '-dash.txt', + } + + api.nvim_win_set_cursor(0, { line_of('names/'), 0 }) + feed('') + poke_eventloop() + eq(names, lines()) + + api.nvim_win_set_cursor(0, { line_of('star*.txt'), 0 }) + feed('') + poke_eventloop() + eq(('zipfile://%s::names/star*.txt'):format(archive), api.nvim_buf_get_name(0)) + eq({ 'content of star*.txt' }, lines()) + + for _, name in ipairs(names) do + edit(('zipfile://%s::names/%s'):format(archive, name)) + eq({ 'content of ' .. name }, lines()) + end + end) + end) + + describe('extract', function() + --- Stage an archive, restart with the cwd inside the test directory, and open it. + local function open_in_cwd(source_dir, source) + local archive = stage(source_dir, source) + clear_zip() + api.nvim_set_current_dir(root) + edit(archive) + return archive end - end) - it('reads entry selectors literally', function() - t.skip(t.is_os('win'), 'N/A: archive contains backslashes in entry paths') - local archive = vim.fs.joinpath(root, 'special.zip') - copy_fixture(vim.fs.joinpath(old_samples, 'testa.zip'), archive) - clear_zip() - - local cases = { - { 'zipglob/a[a].txt', 'a test file with []' }, - { 'zipglob/a*.txt', 'a test file with a*' }, - { 'zipglob/a?.txt', 'a test file with a?' }, - { [[zipglob/a\.txt]], [[a test file with a\]] }, - { [[zipglob/a\\.txt]], [[a test file with a double \]] }, - } - for _, case in ipairs(cases) do - edit(('zipfile://%s::%s'):format(archive, case[1])) - eq({ case[2] }, lines()) - end - end) - - it('lists and opens entries with difficult names', function() - t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - local names = { - 'space name.txt', - 'two spaces.txt', - ' leading.txt', - 'trailing .txt', - [[back\slash.txt]], - [[single'quote.txt]], - 'double"quote.txt', - 'dollar$name.txt', - 'backtick`name.txt', - 'semi;name.txt', - 'pipe|name.txt', - 'amp&name.txt', - 'paren(name).txt', - 'bang!name.txt', - 'hash#name.txt', - 'percent%name.txt', - 'star*.txt', - 'question?.txt', - '[bracket].txt', - '-dash.txt', - } - - edit(archive) - api.nvim_win_set_cursor(0, { line_of('names/'), 0 }) - feed('') - poke_eventloop() - eq(names, lines()) - - api.nvim_win_set_cursor(0, { line_of('star*.txt'), 0 }) - feed('') - poke_eventloop() - eq(('zipfile://%s::names/star*.txt'):format(archive), api.nvim_buf_get_name(0)) - eq({ 'content of star*.txt' }, lines()) - - for _, name in ipairs(names) do - edit(('zipfile://%s::names/%s'):format(archive, name)) - eq({ 'content of ' .. name }, lines()) - end - end) - - it('keeps a leading dash in a path from becoming a backend option', function() - local archive = vim.fs.joinpath(root, 'poc.zip') - copy_fixture(vim.fs.joinpath(old_samples, 'poc.zip'), archive) - clear_zip() - - edit(archive) - eq({ '-d/', 'pwned' }, lines()) - - edit(('zipfile://%s::-d/tmp'):format(archive)) - eq({ '' }, lines()) - end) - - it('lists a valid empty archive as empty', function() - local archive = vim.fs.joinpath(root, 'empty.zip') - local file = assert(io.open(archive, 'wb')) - file:write('PK\5\6' .. string.rep('\0', 18)) - file:close() - clear_zip() - - edit(archive) - - eq({ '' }, lines()) - eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) - eq(true, exec_lua('return vim.b.nvim_zip ~= nil')) - end) - - it('treats archive glob characters literally', function() - t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') - local archive = vim.fs.joinpath(root, 'archive::[*?].zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - copy_fixture(vim.fs.joinpath(old_samples, 'test.zip'), vim.fs.joinpath(root, 'archivex.zip')) - clear_zip() - - edit(archive) - - eq('folder/', lines()[1]) - api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 }) - feed('') - poke_eventloop() - eq({ 'nested payload' }, lines()) - - local uri = ('zipfile://%s::crlf.txt'):format(archive) - edit(uri) - eq(uri, api.nvim_buf_get_name(0)) - eq({ 'one', 'two' }, lines()) - end) - - it('keeps suspicious entry paths visible and readable', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(archive) - api.nvim_win_set_cursor(0, { line_of('../escape.txt'), 0 }) - feed('') - poke_eventloop() - - eq({ 'escape payload' }, lines()) - end) - - it('opens non-zip files normally', function() - local archive = vim.fs.joinpath(root, 'plain.zip') - t.write_file(archive, 'plain text', true) - clear_zip() - - edit(archive) - - eq({ 'plain text' }, lines()) - eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) - end) - - it('keeps the current level when reload fails', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - - edit(archive) - feed('') - poke_eventloop() - eq({ 'nested/', 'root.txt', 'root.java' }, lines()) - - assert(os.remove(archive)) - feed('R') - poke_eventloop() - eq({ 'nested/', 'root.txt', 'root.java' }, lines()) - - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - feed('R') - poke_eventloop() - eq({ 'nested/', 'root.txt', 'root.java' }, lines()) - end) - - it('extracts the entry under the cursor into the current directory', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - api.nvim_set_current_dir(root) - - edit(archive) - feed('') - poke_eventloop() - api.nvim_win_set_cursor(0, { line_of('root.java'), 0 }) - feed('x') - poke_eventloop() - - eq('class root {}\n', t.read_file(vim.fs.joinpath(root, 'root.java'))) - end) - - it('refuses to extract a directory', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - api.nvim_set_current_dir(root) - - edit(archive) - api.nvim_win_set_cursor(0, { line_of('folder/'), 0 }) - feed('x') - poke_eventloop() - - eq(true, exec_capture('messages'):find('not a directory', 1, true) ~= nil) - eq(nil, vim.uv.fs_stat(vim.fs.joinpath(root, 'folder'))) - end) - - it('refuses to overwrite an existing file when extracting', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - local target = vim.fs.joinpath(root, 'root.java') - t.write_file(target, 'untouched', true) - clear_zip() - api.nvim_set_current_dir(root) - - edit(archive) - feed('') - poke_eventloop() - api.nvim_win_set_cursor(0, { line_of('root.java'), 0 }) - feed('x') - poke_eventloop() - - eq(true, exec_capture('messages'):find('already exists', 1, true) ~= nil) - eq('untouched', t.read_file(target)) - end) - - it('extracts suspicious entries without escaping the current directory', function() - local archive = vim.fs.joinpath(root, 'evil.zip') - copy_fixture(vim.fs.joinpath(old_samples, 'evil.zip'), archive) - clear_zip() - api.nvim_set_current_dir(root) - - edit(archive) - eq({ - '../../../../etc/ax-pwn', - 'a/../../../../../../../../../../../../../../../../../../tmp/foobar', - '/tmp/vim_zip/a/b/payload.txt', - }, lines()) - - for _, entry in ipairs(lines()) do - api.nvim_win_set_cursor(0, { line_of(entry), 0 }) + it('extracts the entry under the cursor into the current directory', function() + open_in_cwd(fixtures, 'browser.zip') + feed('') + poke_eventloop() + api.nvim_win_set_cursor(0, { line_of('root.java'), 0 }) feed('x') poke_eventloop() - end - for _, name in ipairs({ 'ax-pwn', 'foobar', 'payload.txt' }) do - eq(true, vim.uv.fs_stat(vim.fs.joinpath(root, name)) ~= nil) - end - eq(nil, vim.uv.fs_stat(vim.fs.joinpath(root, '..', 'ax-pwn'))) - end) + eq('class root {}\n', t.read_file(vim.fs.joinpath(root, 'root.java'))) + end) - it('reports an unavailable backend without claiming the buffer', function() - local archive = vim.fs.joinpath(root, 'browser.zip') - copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) - clear_zip() - exec_lua([[vim.env.PATH = '']]) + it('refuses to extract a directory', function() + open_in_cwd(fixtures, 'browser.zip') + api.nvim_win_set_cursor(0, { line_of('folder/'), 0 }) + feed('x') + poke_eventloop() - edit(archive) - poke_eventloop() + eq(true, exec_capture('messages'):find('not a directory', 1, true) ~= nil) + eq(nil, vim.uv.fs_stat(vim.fs.joinpath(root, 'folder'))) + end) - eq(true, exec_capture('messages'):find('unzip executable not found', 1, true) ~= nil) - eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + it('refuses to overwrite an existing file when extracting', function() + local target = vim.fs.joinpath(root, 'root.java') + stage(fixtures, 'browser.zip') + t.write_file(target, 'untouched', true) + clear_zip() + api.nvim_set_current_dir(root) + + edit(vim.fs.joinpath(root, 'browser.zip')) + feed('') + poke_eventloop() + api.nvim_win_set_cursor(0, { line_of('root.java'), 0 }) + feed('x') + poke_eventloop() + + eq(true, exec_capture('messages'):find('already exists', 1, true) ~= nil) + eq('untouched', t.read_file(target)) + end) + + it('extracts suspicious entries without escaping the current directory', function() + open_in_cwd(old_samples, 'evil.zip') + + eq({ + '../../../../etc/ax-pwn', + 'a/../../../../../../../../../../../../../../../../../../tmp/foobar', + '/tmp/vim_zip/a/b/payload.txt', + }, lines()) + + for _, entry in ipairs(lines()) do + api.nvim_win_set_cursor(0, { line_of(entry), 0 }) + feed('x') + poke_eventloop() + end + + -- Each lands flat in the cwd, never at the path the entry asked for. + for _, name in ipairs({ 'ax-pwn', 'foobar', 'payload.txt' }) do + eq(true, vim.uv.fs_stat(vim.fs.joinpath(root, name)) ~= nil) + end + eq(nil, vim.uv.fs_stat(vim.fs.joinpath(root, '..', 'ax-pwn'))) + end) end) end) From 3fdb74748af936c46d883ebc68ac3735e92fa802 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 29 Jul 2026 14:56:26 -0500 Subject: [PATCH 6/6] fix: review --- runtime/lua/nvim/zip.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua index 7b3c2e000c..39018d517e 100644 --- a/runtime/lua/nvim/zip.lua +++ b/runtime/lua/nvim/zip.lua @@ -13,7 +13,13 @@ local function unzip() return command end ---- Escape a path passed to Info-ZIP, which expands glob patterns even without a shell. +--- Escape a path so that Info-ZIP matches it literally. +--- +--- Info-ZIP matches `*`, `?`, and `[]` in a member selector itself, so this is not shell +--- quoting: passing argv already avoids the shell. For example, unescaped `a[a].txt` +--- silently reads `aa.txt`, and `a?.txt` matches every four-character name. A literal `[` +--- cannot be backslash-escaped, so it is wrapped in a class instead, as is a leading `-`, +--- which would otherwise parse as an option. --- https://github.com/neovim/neovim/blob/7ba955fe079d4aa2554fea8e7235651fafd40efb/runtime/autoload/zip.vim#L316-L339 ---@param value string ---@return string