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)