Compare commits

1 Commits

Author SHA1 Message Date
unkin-agent 949fe00f6a Validate archive entries and stage inside the pack root
- 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 auth errors, validate dir_name
2026-09-05 16:25:49 +10:00
11 changed files with 539 additions and 30 deletions
+2 -2
View File
@@ -6,9 +6,9 @@ max_line_length = 140
globals = { 'vim' }
files['tests/'] = {
read_globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir' },
read_globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir', 'otherfs_dir' },
}
files['tests/minimal.lua'] = {
globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir' },
globals = { 'describe', 'it', 'assert_eq', 'assert_true', 'assert_nil', 'assert_match', 'tmpdir', 'otherfs_dir' },
}
+15 -4
View File
@@ -136,10 +136,17 @@ the UI stays responsive.
1. Resolve `(name, version)` to a URL and headers.
2. `curl` the archive into `stdpath('cache')/vimpack/archives/`.
3. Verify `sha256` when the spec carries one; a mismatch aborts with expected and got.
4. Extract into `stdpath('cache')/vimpack/stage/`, hoisting a single wrapper directory.
5. Rename into `stdpath('data')/site/pack/vimpack/{start,opt}/<name>`, parking the old
tree aside first and restoring it if the rename fails.
6. Record name, version, sha256 and bucket in `stdpath('data')/vimpack/manifest.json`.
4. List every archive member and reject the archive outright if any entry is absolute,
escapes the destination via `..`, is a device or fifo, or is a link pointing outside
the destination. Nothing is written until the whole listing is clean.
5. Extract into `<pack root>/.vimpack-stage/`, hoisting a single wrapper directory.
Staging lives under the pack root so the swap below is a same-filesystem rename;
`stdpath('cache')` and `stdpath('data')` are frequently separate mounts.
6. Rename into `stdpath('data')/site/pack/vimpack/{start,opt}/<name>`, parking the old
tree aside first and restoring it if the rename fails. Should the two ever land on
different filesystems anyway, the staged tree is copied beside the target and that
copy is renamed into place, so the target is never half-written.
7. Record name, version, sha256 and bucket in `stdpath('data')/vimpack/manifest.json`.
A failure at any step leaves the previous install untouched.
@@ -148,6 +155,10 @@ A failure at any step leaves the previous install untouched.
Lazy loading. `opt = true` places a plugin under `opt/` for `:packadd`; there is no
event, filetype or command trigger machinery.
Concurrent Neovim instances. The manifest is rewritten wholesale at the end of a run, so
two `:VimpackInstall` runs overlapping across processes are last-writer-wins and one set
of manifest updates is lost; the installed trees themselves stay consistent.
## Tests
```sh
+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
+239 -17
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()
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
return
end
cb(true)
end
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,
}
+166
View File
@@ -63,6 +63,85 @@ describe('swap', function()
assert_match('cannot move new install into place', err)
assert_eq({ 'old' }, vim.fn.readfile(root .. '/target/marker'))
end)
-- Forced EXDEV: deterministic everywhere, no second mount required.
local function with_exdev_on(path, fn)
local uv = vim.uv or vim.loop
local real = uv.fs_rename
uv.fs_rename = function(from, to)
if from == path then
return nil, 'EXDEV: cross-device link not permitted: ' .. from .. ' -> ' .. to, 'EXDEV'
end
return real(from, to)
end
local ok, err = pcall(fn)
uv.fs_rename = real
if not ok then
error(err, 0)
end
end
it('falls back to a copy when staged and target are on different filesystems', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
vim.fn.mkdir(root .. '/staged/lua', 'p')
vim.fn.writefile({ 'new' }, root .. '/staged/lua/plug.lua')
with_exdev_on(root .. '/staged', function()
assert_true(fsmod.swap(root .. '/staged', root .. '/target'))
end)
assert_eq({ 'new' }, vim.fn.readfile(root .. '/target/lua/plug.lua'))
assert_true(not fsmod.exists(root .. '/target/marker'))
assert_true(not fsmod.exists(root .. '/target.vimpack-old'))
assert_true(not fsmod.exists(root .. '/target.vimpack-new'))
assert_true(not fsmod.exists(root .. '/staged'))
end)
it('restores the previous install when the cross-filesystem copy fails', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
local ok, err
with_exdev_on(root .. '/absent', function()
ok, err = fsmod.swap(root .. '/absent', root .. '/target')
end)
assert_true(not ok)
assert_match('cannot move new install into place', err)
assert_eq({ 'old' }, vim.fn.readfile(root .. '/target/marker'))
assert_true(not fsmod.exists(root .. '/target.vimpack-new'))
end)
it('copies a tree across a real filesystem boundary', function()
local root = tmpdir()
local other = otherfs_dir(root)
if not other then
return
end
vim.fn.mkdir(root .. '/staged/lua', 'p')
vim.fn.writefile({ 'new' }, root .. '/staged/lua/plug.lua')
vim.fn.mkdir(root .. '/staged/empty', 'p')
assert_true(fsmod.copy_tree(root .. '/staged', other .. '/copy'))
assert_eq({ 'new' }, vim.fn.readfile(other .. '/copy/lua/plug.lua'))
assert_true(fsmod.exists(other .. '/copy/empty'))
fsmod.rm_rf(other)
end)
end)
describe('safe_relative', function()
it('accepts a plain relative path', function()
assert_eq('lua/plug.lua', fsmod.safe_relative('./lua/plug.lua'))
assert_eq('lua', fsmod.safe_relative('lua/sub/..'))
end)
it('rejects absolute paths and escapes', function()
assert_nil(fsmod.safe_relative('/etc/passwd'))
assert_nil(fsmod.safe_relative('../evil'))
assert_nil(fsmod.safe_relative('a/../../evil'))
assert_nil(fsmod.safe_relative(''))
end)
end)
describe('extract', function()
@@ -102,4 +181,91 @@ describe('extract', function()
assert_true(not result.ok)
assert_match('extraction failed', result.err)
end)
it('unpacks a zip', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug/lua', 'p')
vim.fn.writefile({ 'return {}' }, root .. '/src/plug/lua/plug.lua')
vim.fn.system({ 'sh', '-c', 'cd ' .. vim.fn.shellescape(root .. '/src') .. ' && zip -qr ../plug.zip plug' })
if vim.v.shell_error ~= 0 then
return
end
local result = wait_extract(root .. '/plug.zip', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/lua/plug.lua'))
end)
it('refuses a tarball that climbs out of the destination', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug', 'p')
vim.fn.writefile({ 'pwned' }, root .. '/src/plug/payload')
vim.fn.system({
'tar',
'-czf',
root .. '/evil.tar.gz',
'-C',
root .. '/src',
'--transform',
's|^plug|../evil|',
'plug/payload',
})
local result = wait_extract(root .. '/evil.tar.gz', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('../evil/payload', result.err)
assert_true(not fsmod.exists(root .. '/evil'))
assert_true(not fsmod.exists(root .. '/dest/payload'))
end)
it('refuses a tarball holding an absolute path', function()
local root = tmpdir()
local victim = root .. '/abs-evil'
vim.fn.writefile({ 'pwned' }, victim)
vim.fn.system({ 'tar', '-cPzf', root .. '/abs.tar.gz', victim })
local result = wait_extract(root .. '/abs.tar.gz', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('is absolute or escapes the destination directory', result.err)
assert_true(not fsmod.exists(root .. '/dest'))
end)
it('refuses a tarball whose symlink points outside the destination', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug', 'p')
vim.fn.system({ 'ln', '-s', '/etc/passwd', root .. '/src/plug/escape' })
vim.fn.system({ 'tar', '-czf', root .. '/link.tar.gz', '-C', root .. '/src', 'plug' })
local result = wait_extract(root .. '/link.tar.gz', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('plug/escape', result.err)
assert_true(not fsmod.exists(root .. '/dest/plug'))
end)
it('refuses a tarball whose symlink climbs out with ..', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug/lua', 'p')
vim.fn.system({ 'ln', '-s', '../../../outside', root .. '/src/plug/lua/escape' })
vim.fn.system({ 'tar', '-czf', root .. '/rel.tar.gz', '-C', root .. '/src', 'plug' })
local result = wait_extract(root .. '/rel.tar.gz', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('plug/lua/escape', result.err)
end)
it('allows a symlink that stays inside the destination', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug/lua', 'p')
vim.fn.writefile({ 'return {}' }, root .. '/src/plug/lua/plug.lua')
vim.fn.system({ 'ln', '-s', 'lua/plug.lua', root .. '/src/plug/alias' })
vim.fn.system({ 'tar', '-czf', root .. '/ok.tar.gz', '-C', root .. '/src', 'plug' })
local result = wait_extract(root .. '/ok.tar.gz', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/alias'))
end)
end)
+27
View File
@@ -126,6 +126,33 @@ describe('install end to end', function()
assert_match(missing, results[1].err)
end)
it('installs with the cache on a different filesystem from the pack root', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
local other = otherfs_dir(root)
if not other then
return
end
local real_cache_dir = fsmod.cache_dir
fsmod.cache_dir = function()
return vim.fs.joinpath(other, 'vimpack')
end
local archive = make_tarball(root, '1.0.0', 'return 1')
local cfg = assert(config.normalize({
plugins = { { name = 'demo.nvim', version = '1.0.0', url = 'file://' .. archive } },
}))
local data = manifest.empty()
local ok, results = pcall(install, cfg, data, pack_root)
fsmod.cache_dir = real_cache_dir
fsmod.rm_rf(other)
assert_true(ok, tostring(results))
assert_true(results[1].ok, tostring(results[1].err))
assert_eq({ 'return 1' }, vim.fn.readfile(vim.fs.joinpath(pack_root, 'start', 'demo.nvim', 'lua', 'demo.lua')))
end)
it('moves a plugin from start to opt', function()
local root = tmpdir()
local pack_root = vim.fs.joinpath(root, 'pack')
+19
View File
@@ -140,4 +140,23 @@ describe('config', function()
assert_nil(cfg)
assert_match('no registry', err)
end)
it('keeps a well-formed dir_name override', function()
local cfg = assert(config.normalize({
registry = reg,
plugins = { { name = 'oil.nvim', version = '2.11.0', dir_name = 'oil' } },
}))
assert_eq('oil', cfg.plugins[1].dir_name)
end)
it('rejects a dir_name that is not a single path component', function()
for _, bad in ipairs({ '../evil', '/abs', 'a/b', '..', '.', '' }) do
local cfg, err = config.normalize({
registry = reg,
plugins = { { name = 'a', version = '1', dir_name = bad } },
})
assert_nil(cfg, 'dir_name ' .. vim.inspect(bad) .. ' should be rejected')
assert_match('dir_name', err)
end
end)
end)
+20
View File
@@ -65,6 +65,26 @@ _G.tmpdir = function()
return dir
end
-- A writable directory on a filesystem other than `reference`, so the EXDEV
-- paths can be exercised for real. nil when the box has no second mount.
_G.otherfs_dir = function(reference)
local uv = vim.uv or vim.loop
local ref = uv.fs_stat(reference)
if not ref then
return nil
end
for _, base in ipairs({ '/dev/shm', '/run/user/' .. tostring(uv.getuid and uv.getuid() or 0), '/var/tmp', '/tmp' }) do
local stat = uv.fs_stat(base)
if stat and stat.dev ~= ref.dev then
local dir = vim.fs.joinpath(base, 'vimpack-xdev-' .. tostring(uv.hrtime()))
if vim.fn.mkdir(dir, 'p') == 1 then
return dir
end
end
end
return nil
end
local files = {}
for i = 1, #_G.arg do
files[#files + 1] = _G.arg[i]
+22
View File
@@ -74,6 +74,28 @@ describe('url escape hatch', function()
assert_eq('https://h/x.zip', resolved.url)
assert_eq('x.zip', resolved.filename)
end)
it('still applies the registry auth headers', function()
vim.env.VIMPACK_TEST_TOKEN = 'sekret'
local resolved = assert(
registry.resolve(
{ name = 'x', version = '1', url = 'https://h/x.zip' },
{ auth = { env = 'VIMPACK_TEST_TOKEN' } }
)
)
assert_eq('Bearer sekret', resolved.headers.Authorization)
vim.env.VIMPACK_TEST_TOKEN = nil
end)
it('propagates an auth failure instead of downloading unauthenticated', function()
vim.env.VIMPACK_TEST_TOKEN = nil
local resolved, err = registry.resolve(
{ name = 'x', version = '1', url = 'https://h/x.zip' },
{ auth = { env = 'VIMPACK_TEST_TOKEN' } }
)
assert_nil(resolved)
assert_match('VIMPACK_TEST_TOKEN', err)
end)
end)
describe('auth headers', function()