mirror of
https://github.com/neovim/neovim.git
synced 2026-08-14 03:04:54 +00:00
Merge #41088 from barrettruth/feat/zip-health-encryption
feat(zip): checkhealth and encrypted entries
This commit is contained in:
@@ -7,37 +7,29 @@
|
||||
Builtin plugin: zip *zip*
|
||||
|
||||
Nvim opens a read-only listing when |:edit| is used with a zip archive. The
|
||||
listing is a |dir| buffer with 'filetype' set to "zip". It requires the
|
||||
`unzip` executable. The module implementing it is private and is not
|
||||
a supported Lua API.
|
||||
listing is a |dir| buffer with 'filetype' set to "zip"; entries open as
|
||||
read-only `zipfile://{archive}::{path}` buffers. Requires the `unzip`
|
||||
executable.
|
||||
|
||||
Recognized extensions include zip, jar, apk, epub, Office and OpenDocument
|
||||
formats, whl, xpi, pkpass, and cbz. Remote archives are fetched by |vim.net|.
|
||||
|
||||
Reading an entry from an encrypted archive prompts for its password.
|
||||
|
||||
*g:loaded_nvim_zip_plugin*
|
||||
To disable the built-in zip browser, set this before startup: >lua
|
||||
|
||||
vim.g.loaded_nvim_zip_plugin = 1
|
||||
<
|
||||
ARCHIVE LISTINGS *zip-listing*
|
||||
MAPPINGS *zip-mappings*
|
||||
|
||||
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|, plus:
|
||||
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.
|
||||
|
||||
INTEGRATIONS *zip-integrations*
|
||||
|
||||
Remote zip URLs are downloaded by |vim.net| and opened in the browser.
|
||||
|
||||
|g:ftplugin_java_source_path| opens the selected entry with |gf|.
|
||||
|
||||
LIMITATIONS *zip-limitations*
|
||||
|
||||
Archive entries cannot be updated. Remote archives are not refetched on
|
||||
@@ -45,6 +37,8 @@ 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.
|
||||
|
||||
Only the original zip encryption is supported, not AES.
|
||||
|
||||
==============================================================================
|
||||
Legacy plugin: zip *old-zip*
|
||||
|
||||
|
||||
@@ -134,6 +134,92 @@ local function read_normally(buf, source)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Describe an Info-ZIP failure. It reports some conditions only through the exit code, and
|
||||
--- says nothing on stderr, so the code is preferred where it is meaningful.
|
||||
---@param code integer
|
||||
---@param stderr string
|
||||
---@return string
|
||||
local function unzip_error(code, stderr)
|
||||
if code == 81 then
|
||||
return 'unsupported compression or encryption'
|
||||
elseif code == 82 then
|
||||
return 'incorrect password'
|
||||
end
|
||||
return stderr ~= '' and stderr or ('unzip exited with %d'):format(code)
|
||||
end
|
||||
|
||||
--- Returned when Info-ZIP wants a password. It only reads one from a terminal, never from a
|
||||
--- pipe, and `-P` would expose it in the process arguments, so this is retried on a pty.
|
||||
local ENCRYPTED = 'zip:encrypted'
|
||||
|
||||
--- Extract one entry into `dir`, prompting for the archive password on a pty.
|
||||
---
|
||||
--- Info-ZIP writes the prompt to the terminal and re-prompts on a wrong password, so the
|
||||
--- exchange is driven until it extracts, gives up, or the user cancels. The password reaches
|
||||
--- it over the pty and never appears in the process arguments.
|
||||
---@param command string
|
||||
---@param source string
|
||||
---@param path string
|
||||
---@param dir string Empty directory to extract into.
|
||||
---@return string? error
|
||||
local function extract_with_password(command, source, path, dir)
|
||||
local buffered, exited = '', nil ---@type string, integer?
|
||||
local ok, job = pcall(vim.fn.jobstart, {
|
||||
command,
|
||||
'-o',
|
||||
'-j',
|
||||
'-d',
|
||||
dir,
|
||||
'--',
|
||||
literal_pattern(source),
|
||||
literal_pattern(path),
|
||||
}, {
|
||||
pty = true,
|
||||
env = { LC_ALL = 'C' },
|
||||
on_stdout = function(_, data)
|
||||
buffered = buffered .. table.concat(data, '')
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
exited = code
|
||||
end,
|
||||
})
|
||||
if not ok or job <= 0 then
|
||||
return 'could not start unzip'
|
||||
end
|
||||
|
||||
local function wanted()
|
||||
return exited ~= nil
|
||||
or buffered:find('password: $') ~= nil
|
||||
or buffered:find('reenter: $') ~= nil
|
||||
end
|
||||
|
||||
local reenter = false
|
||||
while exited == nil do
|
||||
local ready, reason = vim.wait(10000, wanted, 50)
|
||||
if not ready then
|
||||
vim.fn.jobstop(job)
|
||||
-- -2 is CTRL-C.
|
||||
return reason == -2 and 'cancelled' or 'timed out waiting for unzip'
|
||||
end
|
||||
if exited ~= nil then
|
||||
break
|
||||
end
|
||||
local label = reenter and 'Password incorrect, try again: '
|
||||
or ('Password for %s: '):format(path)
|
||||
local password = vim.fn.inputsecret(label)
|
||||
if password == '' then
|
||||
vim.fn.jobstop(job)
|
||||
return 'cancelled'
|
||||
end
|
||||
reenter, buffered = true, ''
|
||||
vim.fn.chansend(job, password .. '\r')
|
||||
end
|
||||
|
||||
if exited ~= 0 then
|
||||
return unzip_error(exited, '')
|
||||
end
|
||||
end
|
||||
|
||||
---@param command string
|
||||
---@param source string
|
||||
---@param path string
|
||||
@@ -171,7 +257,11 @@ local function extract_path(command, source, path, target)
|
||||
return write_err
|
||||
end
|
||||
if result.code ~= 0 then
|
||||
return vim.trim(result.stderr or '')
|
||||
local stderr = vim.trim(result.stderr or '')
|
||||
if stderr:find('unable to get password', 1, true) then
|
||||
return ENCRYPTED
|
||||
end
|
||||
return unzip_error(result.code, stderr)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -313,15 +403,29 @@ function M.read(buf, name)
|
||||
return
|
||||
end
|
||||
local temp = vim.fn.tempname()
|
||||
local dir ---@type string?
|
||||
local err = extract_path(command, source, path, temp)
|
||||
if err then
|
||||
if err == ENCRYPTED then
|
||||
vim.fn.delete(temp)
|
||||
dir = vim.fn.tempname()
|
||||
vim.fn.mkdir(dir, 'p')
|
||||
err = extract_with_password(command, source, path, dir)
|
||||
-- `-j` discards the archive path, so the directory holds exactly the extracted entry.
|
||||
local entry = not err and vim.iter(vim.fs.dir(dir)):next() or nil
|
||||
if entry then
|
||||
temp = vim.fs.joinpath(dir, entry)
|
||||
else
|
||||
err = err or 'no entry was extracted'
|
||||
end
|
||||
end
|
||||
if err then
|
||||
vim.fn.delete(dir or temp, dir and 'rf' or '')
|
||||
set_readonly(buf)
|
||||
notify('zip', ('unable to read %s from %s: %s'):format(path, source, err))
|
||||
return
|
||||
end
|
||||
local ok, read_err = pcall(read_tempfile, buf, temp)
|
||||
vim.fn.delete(temp)
|
||||
vim.fn.delete(dir or temp, dir and 'rf' or '')
|
||||
if not ok then
|
||||
set_readonly(buf)
|
||||
notify('zip', tostring(read_err))
|
||||
|
||||
60
runtime/lua/nvim/zip/health.lua
Normal file
60
runtime/lua/nvim/zip/health.lua
Normal file
@@ -0,0 +1,60 @@
|
||||
local M = {}
|
||||
|
||||
local health = vim.health
|
||||
|
||||
local function check_backend()
|
||||
health.start('nvim.zip: backend')
|
||||
|
||||
local exe = vim.fn.exepath('unzip')
|
||||
if exe == '' then
|
||||
health.error('`unzip` executable not found', {
|
||||
'Install Info-ZIP `unzip` to browse and read archives.',
|
||||
'Or `:packadd old-zip` to use the legacy plugin.',
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
local out = vim.system({ exe, '-v' }, { text = true }):wait()
|
||||
local version = vim.split(out.stdout or '', '\n')[1] or ''
|
||||
health.ok(('`unzip` found: %s'):format(exe))
|
||||
if version ~= '' then
|
||||
health.info(version)
|
||||
end
|
||||
end
|
||||
|
||||
--- @return boolean Whether an implementation that needs the backend is handling archives.
|
||||
local function check_active()
|
||||
health.start('nvim.zip: active implementation')
|
||||
|
||||
local legacy = vim.fn.exists('#zip') == 1
|
||||
local builtin = vim.fn.exists('#nvim.zip') == 1
|
||||
|
||||
if legacy then
|
||||
health.info('`old-zip` is loaded, so it handles archives instead of zip.lua')
|
||||
health.info('zip.lua defers to it while it is loaded')
|
||||
-- `old-zip` shells out to the same backend.
|
||||
return true
|
||||
end
|
||||
|
||||
if not builtin then
|
||||
if vim.g.loaded_nvim_zip_plugin ~= nil then
|
||||
health.info('Disabled (`g:loaded_nvim_zip_plugin` is set).')
|
||||
else
|
||||
health.warn('No zip plugin is active')
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
health.ok('zip.lua is active')
|
||||
return true
|
||||
end
|
||||
|
||||
function M.check()
|
||||
-- Report the backend only when something is actually going to run it, so that disabling the
|
||||
-- plugin does not report a missing `unzip` as an error.
|
||||
if check_active() then
|
||||
check_backend()
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
BIN
test/functional/fixtures/zip/encrypted.zip
Normal file
BIN
test/functional/fixtures/zip/encrypted.zip
Normal file
Binary file not shown.
@@ -44,7 +44,7 @@ describe(':checkhealth', function()
|
||||
it('getcompletion()', function()
|
||||
clear { args = { '-u', 'NORC', '+set runtimepath+=test/functional/fixtures' } }
|
||||
|
||||
eq('vim.deprecated', getcompletion('vim', 'checkhealth')[1])
|
||||
eq('nvim.zip', getcompletion('vim', 'checkhealth')[1])
|
||||
eq('vim.provider', getcompletion('vim.prov', 'checkhealth')[1])
|
||||
eq('vim.lsp', getcompletion('vim.ls', 'checkhealth')[1])
|
||||
|
||||
|
||||
@@ -407,6 +407,37 @@ describe('nvim.zip', function()
|
||||
end)
|
||||
end)
|
||||
|
||||
it('prompts for the password of an encrypted archive', function()
|
||||
local archive = stage(fixtures, 'encrypted.zip')
|
||||
clear_zip()
|
||||
|
||||
edit(archive)
|
||||
eq({ 'secret.txt' }, lines())
|
||||
|
||||
-- The password is queued first, so that the prompt consumes it from typeahead.
|
||||
exec_lua(function(uri)
|
||||
vim.api.nvim_input('hunter2<CR>')
|
||||
vim.api.nvim_cmd({ cmd = 'edit', args = { uri }, magic = { file = false, bar = false } }, {})
|
||||
end, ('zipfile://%s::secret.txt'):format(archive))
|
||||
poke_eventloop()
|
||||
|
||||
eq({ 'secret content' }, lines())
|
||||
end)
|
||||
|
||||
it('reports an incorrect archive password', function()
|
||||
local archive = stage(fixtures, 'encrypted.zip')
|
||||
clear_zip()
|
||||
|
||||
-- Info-ZIP allows three attempts before giving up.
|
||||
exec_lua(function(uri)
|
||||
vim.api.nvim_input('no1<CR>no2<CR>no3<CR>')
|
||||
vim.api.nvim_cmd({ cmd = 'edit', args = { uri }, magic = { file = false, bar = false } }, {})
|
||||
end, ('zipfile://%s::secret.txt'):format(archive))
|
||||
poke_eventloop()
|
||||
|
||||
eq(true, exec_capture('messages'):find('incorrect password', 1, true) ~= nil)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user