From d03b4cfb7ab03bd00c04749d93ba630c267a3e82 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sat, 1 Aug 2026 14:55:02 -0500
Subject: [PATCH 1/2] feat(zip): checkhealth and encrypted entries
Problem:
There is no health check. Encrypted entries cannot be read at all, since
Info-ZIP takes a password only from a terminal and `-P` would expose it
in the process arguments.
Solution:
Add `:checkhealth nvim.zip`, reporting the backend and which
implementation is handling archives. Prompt for the password on a pty,
extracting to a file so the entry's bytes stay off the terminal. Report
Info-ZIP's exit code rather than inferring a cause, so an archive using
AES is not reported as a failed decryption.
---
runtime/doc/zip.txt | 30 +++---
runtime/lua/nvim/zip.lua | 110 ++++++++++++++++++++-
runtime/lua/nvim/zip/health.lua | 62 ++++++++++++
test/functional/fixtures/zip/encrypted.zip | Bin 0 -> 145 bytes
test/functional/plugin/zip_spec.lua | 31 ++++++
5 files changed, 212 insertions(+), 21 deletions(-)
create mode 100644 runtime/lua/nvim/zip/health.lua
create mode 100644 test/functional/fixtures/zip/encrypted.zip
diff --git a/runtime/doc/zip.txt b/runtime/doc/zip.txt
index 4dae097e68..03dc389f8b 100644
--- a/runtime/doc/zip.txt
+++ b/runtime/doc/zip.txt
@@ -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*
diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua
index 39018d517e..c1f12689d7 100644
--- a/runtime/lua/nvim/zip.lua
+++ b/runtime/lua/nvim/zip.lua
@@ -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))
diff --git a/runtime/lua/nvim/zip/health.lua b/runtime/lua/nvim/zip/health.lua
new file mode 100644
index 0000000000..1c9798b155
--- /dev/null
+++ b/runtime/lua/nvim/zip/health.lua
@@ -0,0 +1,62 @@
+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 yields to it and removes its own autocommands')
+ -- `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.warn('zip.lua is disabled by `g:loaded_nvim_zip_plugin`', {
+ 'Unset it before startup to enable the builtin zip plugin.',
+ })
+ 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
diff --git a/test/functional/fixtures/zip/encrypted.zip b/test/functional/fixtures/zip/encrypted.zip
new file mode 100644
index 0000000000000000000000000000000000000000..fe813c012030bcbd91104a68edf20aa6bb4271db
GIT binary patch
literal 145
zcmWIWW@Zs#U}S&*gG5nrX$A%ceg*~xE(QjM;?(4#)DpdtijudNulX|C>%M6I-9O=+
yf7Dv$8RpKi(rXuXr?UolGct)V;5GzeG>k+T8Q{&z1`=gtU}UghU|?_taTowFH5oSm
literal 0
HcmV?d00001
diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua
index 407f0766e3..41f87370fb 100644
--- a/test/functional/plugin/zip_spec.lua
+++ b/test/functional/plugin/zip_spec.lua
@@ -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')
+ 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('no1no2no3')
+ 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)
From 1e757a2feb57819c9c8d2e84dd0ef4787a4118bb Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Mon, 3 Aug 2026 11:53:12 -0500
Subject: [PATCH 2/2] fix(zip): do not warn when zip.lua is disabled
---
runtime/lua/nvim/zip/health.lua | 6 ++----
test/functional/plugin/health_spec.lua | 2 +-
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/runtime/lua/nvim/zip/health.lua b/runtime/lua/nvim/zip/health.lua
index 1c9798b155..79489e280c 100644
--- a/runtime/lua/nvim/zip/health.lua
+++ b/runtime/lua/nvim/zip/health.lua
@@ -31,16 +31,14 @@ local function check_active()
if legacy then
health.info('`old-zip` is loaded, so it handles archives instead of zip.lua')
- health.info('zip.lua yields to it and removes its own autocommands')
+ 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.warn('zip.lua is disabled by `g:loaded_nvim_zip_plugin`', {
- 'Unset it before startup to enable the builtin zip plugin.',
- })
+ health.info('Disabled (`g:loaded_nvim_zip_plugin` is set).')
else
health.warn('No zip plugin is active')
end
diff --git a/test/functional/plugin/health_spec.lua b/test/functional/plugin/health_spec.lua
index 5e3d935553..050a820e27 100644
--- a/test/functional/plugin/health_spec.lua
+++ b/test/functional/plugin/health_spec.lua
@@ -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])