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