Validate archive entries and stage inside the pack root
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

- Reject archive members that are absolute, escape via .., are special files, or link outside the destination, before anything is written

- Stage under the pack root so the swap is a same-filesystem rename, with an EXDEV copy fallback; propagate registry auth errors and validate dir_name
This commit is contained in:
2026-09-05 16:32:47 +10:00
parent ae951a9ea6
commit ca55c9dd9f
14 changed files with 544 additions and 35 deletions
+13 -1
View File
@@ -63,7 +63,19 @@ function M.normalize(opts)
end
spec.opt = spec.opt == true
spec.version = spec.version or 'url'
spec.dir_name = spec.dir_name or require('vimpack.util').slug(spec.name)
local slug = require('vimpack.util').slug
if spec.dir_name ~= nil then
if type(spec.dir_name) ~= 'string' or spec.dir_name == '' then
return nil, string.format('plugin %q: dir_name must be a non-empty string', spec.name)
end
-- A dir_name is joined onto the pack root, so it must be a single
-- component: anything slug() would rewrite, plus "." and "..", is out.
if spec.dir_name == '.' or spec.dir_name == '..' or spec.dir_name ~= slug(spec.dir_name) then
return nil,
string.format('plugin %q: dir_name %q must not contain path separators or ".."', spec.name, spec.dir_name)
end
end
spec.dir_name = spec.dir_name or slug(spec.name)
plugins[#plugins + 1] = spec
end
cfg.plugins = plugins
+240 -18
View File
@@ -64,6 +64,83 @@ function M.strip_top_level(dir)
return dir
end
--- Recursively copy `src` to `dst`, fsyncing every file as it lands.
function M.copy_tree(src, dst)
local stat, serr = uv.fs_lstat(src)
if not stat then
return false, string.format('cannot stat %s: %s', src, serr)
end
if stat.type == 'link' then
local link, lerr = uv.fs_readlink(src)
if not link then
return false, string.format('cannot read symlink %s: %s', src, lerr)
end
local ok, err = uv.fs_symlink(link, dst)
return ok and true or false, ok and nil or string.format('cannot recreate symlink %s: %s', dst, err)
end
if stat.type == 'directory' then
local ok, err = uv.fs_mkdir(dst, 493)
if not ok and not M.exists(dst) then
return false, string.format('cannot create %s: %s', dst, err)
end
local handle, herr = uv.fs_scandir(src)
if not handle then
return false, string.format('cannot read %s: %s', src, herr)
end
while true do
local name = uv.fs_scandir_next(handle)
if not name then
return true
end
local cok, cerr = M.copy_tree(vim.fs.joinpath(src, name), vim.fs.joinpath(dst, name))
if not cok then
return false, cerr
end
end
end
local ok, err = uv.fs_copyfile(src, dst)
if not ok then
return false, string.format('cannot copy %s: %s', src, err)
end
local fd = uv.fs_open(dst, 'r', 420)
if fd then
uv.fs_fsync(fd)
uv.fs_close(fd)
end
return true
end
--- Rename `src` onto `dst`. Staging normally shares a filesystem with the pack
--- root so this is a plain atomic rename; when it does not, copy beside the
--- target first and rename that, so `dst` is never a partially written tree.
local function move_into_place(src, dst)
local ok, err, code = uv.fs_rename(src, dst)
if ok then
return true
end
if code ~= 'EXDEV' and not tostring(err):match('^EXDEV') then
return false, err
end
local landing = dst .. '.vimpack-new'
M.rm_rf(landing)
local copied, cerr = M.copy_tree(src, landing)
if not copied then
M.rm_rf(landing)
return false, cerr
end
local renamed, rerr = uv.fs_rename(landing, dst)
if not renamed then
M.rm_rf(landing)
return false, rerr
end
M.rm_rf(src)
return true
end
--- Replace `target` with `staged` without ever leaving a half-written plugin
--- behind: the old tree is parked aside first and restored on failure.
function M.swap(staged, target)
@@ -79,7 +156,7 @@ function M.swap(staged, target)
end
end
local ok, err = uv.fs_rename(staged, target)
local ok, err = move_into_place(staged, target)
if not ok then
if had_previous then
uv.fs_rename(backup, target)
@@ -91,41 +168,186 @@ function M.swap(staged, target)
return true
end
--- Extract an archive into `dest` asynchronously. Calls cb(ok, err).
--- Collapse `.` and `..` in an archive member path. Returns nil when the entry
--- is absolute or climbs above the directory it is extracted into.
function M.safe_relative(path)
if type(path) ~= 'string' or path == '' then
return nil
end
if path:sub(1, 1) == '/' or path:match('^%a:[/\\]') then
return nil
end
local parts = {}
for part in path:gmatch('[^/]+') do
if part == '..' then
if #parts == 0 then
return nil
end
parts[#parts] = nil
elseif part ~= '.' then
parts[#parts + 1] = part
end
end
return table.concat(parts, '/')
end
--- True when `target`, followed from the directory holding `entry`, lands
--- outside the extraction root.
local function link_escapes(entry, target)
if type(target) ~= 'string' or target == '' or target:sub(1, 1) == '/' then
return true
end
local dir = entry:match('^(.*)/[^/]*$')
return M.safe_relative(dir and (dir .. '/' .. target) or target) == nil
end
local SPECIAL = { c = 'a character device', b = 'a block device', p = 'a fifo', s = 'a socket' }
--- Parse `tar -tvf` output into { name, kind, link, hardlink } records.
local function parse_tar_listing(out)
local entries = {}
for line in (out or ''):gmatch('[^\n]+') do
local mode, rest = line:match('^(%S+)%s+%S+%s+%S+%s+%S+%s+%S+%s+(.+)$')
if not mode then
return nil, string.format('cannot parse archive listing line %q', line)
end
local entry = { kind = mode:sub(1, 1), name = rest }
if entry.kind == 'l' then
local name, target = rest:match('^(.*) %-> (.*)$')
entry.name, entry.link = name or rest, target
elseif entry.kind == 'h' then
local name, target = rest:match('^(.*) link to (.*)$')
entry.name, entry.hardlink = name or rest, target
end
entries[#entries + 1] = entry
end
return entries
end
--- Parse `unzip -Z1` output; zip symlinks are caught by the post-extract walk.
local function parse_zip_listing(out)
local entries = {}
for line in (out or ''):gmatch('[^\n]+') do
entries[#entries + 1] = { kind = '-', name = line }
end
return entries
end
--- Reject anything that would write outside the extraction root. Returns an
--- error message naming the offending entry, or nil when every entry is safe.
function M.check_entries(entries, archive)
for _, entry in ipairs(entries) do
local why
if SPECIAL[entry.kind] then
why = 'is ' .. SPECIAL[entry.kind]
elseif not M.safe_relative(entry.name) then
why = 'is absolute or escapes the destination directory'
elseif entry.link and link_escapes(entry.name, entry.link) then
why = string.format('is a symlink to %q outside the destination directory', entry.link)
elseif entry.hardlink and not M.safe_relative(entry.hardlink) then
why = string.format('is a hard link to %q outside the destination directory', entry.hardlink)
end
if why then
return string.format('refusing to extract %s: entry %q %s', archive, entry.name, why)
end
end
end
--- Backstop for formats whose listing does not describe links: walk what landed
--- on disk and report the first symlink pointing out of `dest`.
local function escaping_symlink(dest)
for name, kind in vim.fs.dir(dest, { depth = 64 }) do
if kind == 'link' then
local target = uv.fs_readlink(vim.fs.joinpath(dest, name))
if link_escapes(name, target) then
return name, target or '?'
end
end
end
end
local function spawn(cmd, cb)
local ok, err = pcall(vim.system, cmd, { text = true }, function(res)
vim.schedule(function()
cb(res)
end)
end)
if not ok then
cb(nil, string.format('cannot run %s: %s (is it installed?)', cmd[1], err))
end
end
--- Extract an archive into `dest` asynchronously. Calls cb(ok, err). Every
--- member is enumerated and validated first, so a hostile archive is rejected
--- before anything is written.
function M.extract(archive, dest, cb)
M.mkdir_p(dest)
local kind = util.archive_kind(archive)
local cmd
local list, unpack
if kind == 'tar' then
cmd = { 'tar', '-xf', archive, '-C', dest }
list = { 'tar', '-tvf', archive }
unpack = { 'tar', '-xf', archive, '-C', dest }
elseif kind == 'zip' then
cmd = { 'unzip', '-q', '-o', archive, '-d', dest }
list = { 'unzip', '-Z1', archive }
unpack = { 'unzip', '-q', '-o', archive, '-d', dest }
else
cb(false, string.format('unsupported archive type for %s (expected .tar.*, .tgz or .zip)', archive))
return
end
local ok, handle = pcall(vim.system, cmd, { text = true }, function(res)
vim.schedule(function()
if res.code ~= 0 then
spawn(list, function(res, serr)
if not res then
cb(false, serr)
return
end
if res.code ~= 0 then
local detail =
string.format('cannot list entries (%s exited %d): %s', list[1], res.code, vim.trim(res.stderr or ''))
cb(false, string.format('extraction failed for %s: %s', archive, detail))
return
end
local parse = kind == 'tar' and parse_tar_listing or parse_zip_listing
local entries, perr = parse(res.stdout)
if not entries then
cb(false, string.format('extraction failed for %s: %s', archive, perr))
return
end
local unsafe = M.check_entries(entries, archive)
if unsafe then
cb(false, unsafe)
return
end
M.mkdir_p(dest)
spawn(unpack, function(xres, xerr)
if not xres then
M.rm_rf(dest)
cb(false, xerr)
return
end
if xres.code ~= 0 then
M.rm_rf(dest)
local detail = string.format('(%s exited %d): %s', unpack[1], xres.code, vim.trim(xres.stderr or ''))
cb(false, string.format('extraction failed for %s %s', archive, detail))
return
end
local name, target = escaping_symlink(dest)
if name then
M.rm_rf(dest)
cb(
false,
string.format(
'extraction failed for %s (%s exited %d): %s',
'refusing to extract %s: entry %q is a symlink to %q outside the destination directory',
archive,
cmd[1],
res.code,
vim.trim(res.stderr or '')
name,
target
)
)
else
cb(true)
return
end
cb(true)
end)
end)
if not ok then
cb(false, string.format('cannot run %s: %s (is it installed?)', cmd[1], handle))
end
end
--- Download a URL to `out` asynchronously with curl. Calls cb(ok, err).
+6 -4
View File
@@ -41,10 +41,12 @@ function M.run_tasks(items, concurrency, worker, done)
end
end
local function stage_path(spec)
--- Stage inside the pack root so the final swap is a same-filesystem rename;
--- the cache lives under stdpath('cache'), which is often a different mount.
local function stage_path(spec, root)
return vim.fs.joinpath(
fsmod.cache_dir(),
'stage',
root or fsmod.pack_root(),
'.vimpack-stage',
string.format('%s.%d', util.slug(spec.name), vim.uv.hrtime() % 1000000)
)
end
@@ -64,7 +66,7 @@ function M.install_one(spec, ctx, cb)
util.slug(spec.name) .. '-' .. spec.version .. '-' .. resolved.filename
)
local target = fsmod.target_dir(spec, ctx.pack_root)
local staged = stage_path(spec)
local staged = stage_path(spec, ctx.pack_root)
local function fail(message)
fsmod.rm_rf(staged)
+9 -1
View File
@@ -129,9 +129,17 @@ end
--- A spec-level `url` short-circuits registry resolution entirely.
function M.resolve(spec, registry)
if spec.url then
local headers = {}
if registry then
local herr
headers, herr = M.build_headers(registry)
if not headers then
return nil, string.format('plugin %q: %s', spec.name, herr)
end
end
return {
url = spec.url,
headers = registry and select(1, M.build_headers(registry)) or {},
headers = headers,
filename = spec.filename or util.url_basename(spec.url),
sha256 = spec.sha256,
}