Merge #40953 from barrettruth/feat/zip-extract

feat(zip): extract archive entries
This commit is contained in:
Justin M. Keyes
2026-07-31 15:45:14 -04:00
committed by GitHub
5 changed files with 489 additions and 281 deletions

View File

@@ -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*

View File

@@ -13,12 +13,25 @@ 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
local function literal_pattern(value)
return (value:gsub('\\', '\\\\'):gsub('%?', '\\?'):gsub('%*', '\\*'):gsub('%[', '[[]'))
return (
value
:gsub('\\', '\\\\')
:gsub('%?', '\\?')
:gsub('%*', '\\*')
:gsub('%[', '[[]')
:gsub('^%-', '[-]')
)
end
---@param source string
@@ -38,6 +51,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 })
@@ -388,9 +404,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('<Plug>(nvim-zip-extract)', 'n') == 0 then
vim.keymap.set('n', 'x', '<Plug>(nvim-zip-extract)', { buffer = buf, silent = true })
end
api.nvim_buf_call(buf, function()
vim.wo.wrap = false
end)

View File

@@ -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', '<Plug>(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

View File

@@ -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,302 +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' },
lines()
)
eq('zip', api.nvim_get_option_value('filetype', { buf = 0 }))
eq(true, exec_capture('syntax list zipDirectory'):find('zipDirectory', 1, true) ~= nil)
feed('<CR>')
poke_eventloop()
eq({ 'nested/', 'root.txt', 'root.java' }, lines())
feed('<CR>')
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('<CR>')
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('<CR>')
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('<CR>')
poke_eventloop()
eq({ 'nested/', 'root.txt', 'root.java' }, lines())
feed('<CR>')
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('<CR>')
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('<CR>')
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('<CR>')
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('<CR>')
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('<CR>')
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('<CR>')
poke_eventloop()
eq(names, lines())
api.nvim_win_set_cursor(0, { line_of('star*.txt'), 0 })
feed('<CR>')
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()
it('extracts the entry under the cursor into the current directory', function()
open_in_cwd(fixtures, 'browser.zip')
feed('<CR>')
poke_eventloop()
api.nvim_win_set_cursor(0, { line_of('root.java'), 0 })
feed('x')
poke_eventloop()
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)
eq('class root {}\n', t.read_file(vim.fs.joinpath(root, 'root.java')))
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()
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)
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('folder/', lines()[1])
api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 })
feed('<CR>')
poke_eventloop()
eq({ 'nested payload' }, lines())
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)
local uri = ('zipfile://%s::crlf.txt'):format(archive)
edit(uri)
eq(uri, api.nvim_buf_get_name(0))
eq({ 'one', 'two' }, lines())
end)
edit(vim.fs.joinpath(root, 'browser.zip'))
feed('<CR>')
poke_eventloop()
api.nvim_win_set_cursor(0, { line_of('root.java'), 0 })
feed('x')
poke_eventloop()
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()
eq(true, exec_capture('messages'):find('already exists', 1, true) ~= nil)
eq('untouched', t.read_file(target))
end)
edit(archive)
api.nvim_win_set_cursor(0, { line_of('../escape.txt'), 0 })
feed('<CR>')
poke_eventloop()
it('extracts suspicious entries without escaping the current directory', function()
open_in_cwd(old_samples, 'evil.zip')
eq({ 'escape payload' }, lines())
end)
eq({
'../../../../etc/ax-pwn',
'a/../../../../../../../../../../../../../../../../../../tmp/foobar',
'/tmp/vim_zip/a/b/payload.txt',
}, lines())
it('opens non-zip files normally', function()
local archive = vim.fs.joinpath(root, 'plain.zip')
t.write_file(archive, 'plain text', true)
clear_zip()
for _, entry in ipairs(lines()) do
api.nvim_win_set_cursor(0, { line_of(entry), 0 })
feed('x')
poke_eventloop()
end
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('<CR>')
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('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 = '']])
edit(archive)
poke_eventloop()
eq(true, exec_capture('messages'):find('unzip executable not found', 1, true) ~= nil)
eq(false, exec_lua('return vim.b.nvim_dir ~= nil'))
-- 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)