mirror of
https://github.com/neovim/neovim.git
synced 2026-08-03 14:19:15 +00:00
Merge #40953 from barrettruth/feat/zip-extract
feat(zip): extract archive entries
This commit is contained in:
@@ -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*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user