Files
vimpack/tests/fs_spec.lua
T
unkin-agent 6e1b018adb
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Validate zip symlinks and backslash paths before extraction
- treat `\` as a separator in safe_relative so `\abs` and `..\..\x` are refused
- read zip symlink targets with `unzip -p` off the `unzip -Z -l` listing and reject escapes before unpacking
2026-09-05 16:59:24 +10:00

357 lines
13 KiB
Lua

local fsmod = require('vimpack.fs')
describe('scan', function()
it('maps directories to their bucket', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/start/a/lua', 'p')
vim.fn.mkdir(root .. '/opt/b', 'p')
vim.fn.writefile({ '' }, root .. '/start/loose.txt')
local found = fsmod.scan(root)
assert_eq(false, found.a.opt)
assert_eq(true, found.b.opt)
assert_nil(found['loose.txt'])
end)
it('returns nothing for a missing root', function()
assert_eq({}, fsmod.scan(tmpdir() .. '/absent'))
end)
end)
describe('target_dir', function()
it('splits start and opt', function()
assert_eq('/r/start/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = false }, '/r'))
assert_eq('/r/opt/a', fsmod.target_dir({ name = 'a', dir_name = 'a', opt = true }, '/r'))
end)
end)
describe('strip_top_level', function()
it('hoists a single wrapper directory', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/oil.nvim-2.11.0/lua', 'p')
assert_eq(root .. '/oil.nvim-2.11.0', fsmod.strip_top_level(root))
end)
it('leaves a flat archive alone', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/lua', 'p')
vim.fn.mkdir(root .. '/plugin', 'p')
assert_eq(root, fsmod.strip_top_level(root))
end)
end)
describe('swap', function()
it('replaces an existing install', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
vim.fn.mkdir(root .. '/staged', 'p')
vim.fn.writefile({ 'new' }, root .. '/staged/marker')
assert_true(fsmod.swap(root .. '/staged', root .. '/target'))
assert_eq({ 'new' }, vim.fn.readfile(root .. '/target/marker'))
assert_true(not fsmod.exists(root .. '/target.vimpack-old'))
end)
it('keeps the previous install when the staged tree is missing', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/target', 'p')
vim.fn.writefile({ 'old' }, root .. '/target/marker')
local ok, err = fsmod.swap(root .. '/absent', root .. '/target')
assert_true(not ok)
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)
it('treats a backslash as a separator', function()
assert_nil(fsmod.safe_relative('\\absolute_backslash.txt'))
assert_nil(fsmod.safe_relative('..\\..\\..\\deep_escape.txt'))
assert_nil(fsmod.safe_relative('a\\..\\..\\evil'))
assert_nil(fsmod.safe_relative('C:\\windows\\evil'))
assert_eq('lua/plug.lua', fsmod.safe_relative('lua\\plug.lua'))
end)
end)
describe('extract', function()
local function wait_extract(archive, dest)
local result
fsmod.extract(archive, dest, function(ok, err)
result = { ok = ok, err = err }
end)
vim.wait(10000, function()
return result ~= nil
end)
return result
end
it('unpacks a tarball', 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({ 'tar', '-czf', root .. '/plug.tar.gz', '-C', root .. '/src', 'plug' })
local result = wait_extract(root .. '/plug.tar.gz', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/lua/plug.lua'))
assert_eq(root .. '/dest/plug', fsmod.strip_top_level(root .. '/dest'))
end)
it('rejects an unknown archive type', function()
local result = wait_extract(tmpdir() .. '/thing.rpm', tmpdir() .. '/dest')
assert_true(not result.ok)
assert_match('unsupported archive type', result.err)
end)
it('reports a corrupt tarball', function()
local root = tmpdir()
vim.fn.writefile({ 'definitely not a tarball' }, root .. '/bad.tar.gz')
local result = wait_extract(root .. '/bad.tar.gz', root .. '/dest')
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)
local function sh(root, script)
vim.fn.system({ 'sh', '-c', 'cd ' .. vim.fn.shellescape(root) .. ' && ' .. script })
return vim.v.shell_error == 0
end
-- A symlink out of the destination followed by a member written *through* it.
-- unzip would create the link and only then notice, so the whole archive has
-- to be refused off the listing, before a single byte lands.
it('refuses a zip that writes through an escaping symlink', function()
local root = tmpdir()
local made = sh(
root,
'mkdir -p src && cd src && ln -s ../../../outside link && zip -qy ../evil.zip link'
.. ' && rm link && mkdir link && echo pwned > link/pwned.txt && zip -qgy ../evil.zip link/pwned.txt'
)
if not made then
return
end
local result = wait_extract(root .. '/evil.zip', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('is a symlink to', result.err)
assert_match('../../../outside', result.err)
assert_true(not fsmod.exists(root .. '/dest'))
assert_true(not fsmod.exists(root .. '/outside'))
assert_true(not fsmod.exists(vim.fs.dirname(root) .. '/outside'))
end)
it('refuses a zip whose symlink points at an absolute path', function()
local root = tmpdir()
if not sh(root, 'mkdir -p src/plug && ln -s /etc/passwd src/plug/escape && cd src && zip -qry ../abs.zip plug') then
return
end
local result = wait_extract(root .. '/abs.zip', 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'))
end)
it('allows a zip symlink that stays inside the destination', function()
local root = tmpdir()
if
not sh(
root,
'mkdir -p src/plug/lua && echo "return {}" > src/plug/lua/plug.lua'
.. ' && ln -s lua/plug.lua src/plug/alias && cd src && zip -qry ../ok.zip plug'
)
then
return
end
local result = wait_extract(root .. '/ok.zip', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/alias'))
end)
it('reads the symlink target of a member whose name looks like a glob', function()
local root = tmpdir()
if
not sh(
root,
'mkdir -p src/plug && ln -s ../../../outside "src/plug/od[d]*link" && cd src && zip -qry ../glob.zip plug'
)
then
return
end
local result = wait_extract(root .. '/glob.zip', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('is a symlink to', result.err)
assert_true(not fsmod.exists(root .. '/dest'))
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)