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])