5809a843c8
Installing a plugin no longer needs unzip or tar on PATH, and entry paths are vetted in Lua instead of trusting an external tool. Add arti-pack.archive: a zip reader over zzlib's inflate and a tar reader over its gunzip. Reject absolute, parent-walking, backslashed and NUL-bearing entry names, and any entry that is not a regular file or directory. Verify every zip entry's CRC32 and size, and fail the install on mismatch. Carry only the executable bit from archive modes. Replace fs.extract_cmd with fs.extract and call it synchronously. Drop unzip from the test pipeline and the stated requirements.
341 lines
10 KiB
Lua
341 lines
10 KiB
Lua
local t = require('tests.harness')
|
|
local archive = require('arti-pack.archive')
|
|
local zzlib = require('arti-pack.vendor.zzlib')
|
|
|
|
local function tmpdir()
|
|
local dir = vim.fn.tempname()
|
|
vim.fn.mkdir(dir, 'p')
|
|
return dir
|
|
end
|
|
|
|
local function le(n, bytes)
|
|
local out = {}
|
|
for _ = 1, bytes do
|
|
table.insert(out, string.char(n % 256))
|
|
n = math.floor(n / 256)
|
|
end
|
|
return table.concat(out)
|
|
end
|
|
|
|
-- Fixtures are built here rather than shelled out to zip/tar so the hostile
|
|
-- entry names below are exactly the bytes under test.
|
|
local function zipf(entries)
|
|
local locals, central, offset = {}, {}, 0
|
|
for _, e in ipairs(entries) do
|
|
local data = e.data or ''
|
|
local crc = zzlib.crc32(data)
|
|
local head = 'PK\3\4'
|
|
.. le(20, 2)
|
|
.. le(e.flags or 0, 2)
|
|
.. le(0, 2)
|
|
.. le(0, 4)
|
|
.. le(e.crc or crc, 4)
|
|
.. le(#data, 4)
|
|
.. le(#data, 4)
|
|
.. le(#e.name, 2)
|
|
.. le(0, 2)
|
|
.. e.name
|
|
table.insert(locals, head .. data)
|
|
table.insert(
|
|
central,
|
|
'PK\1\2'
|
|
.. le(e.made_by or (3 * 256 + 20), 2)
|
|
.. le(20, 2)
|
|
.. le(e.flags or 0, 2)
|
|
.. le(0, 2)
|
|
.. le(0, 4)
|
|
.. le(e.crc or crc, 4)
|
|
.. le(#data, 4)
|
|
.. le(#data, 4)
|
|
.. le(#e.name, 2)
|
|
.. le(0, 2)
|
|
.. le(0, 2)
|
|
.. le(0, 2)
|
|
.. le(0, 2)
|
|
.. le(e.attr or 0, 4)
|
|
.. le(offset, 4)
|
|
.. e.name
|
|
)
|
|
offset = offset + #head + #data
|
|
end
|
|
local body = table.concat(locals)
|
|
local cd = table.concat(central)
|
|
local comment = entries.comment or ''
|
|
local eocd = 'PK\5\6'
|
|
.. le(0, 2)
|
|
.. le(0, 2)
|
|
.. le(#entries, 2)
|
|
.. le(#entries, 2)
|
|
.. le(#cd, 4)
|
|
.. le(#body, 4)
|
|
.. le(#comment, 2)
|
|
.. comment
|
|
return body .. cd .. eocd
|
|
end
|
|
|
|
-- A gzip member whose DEFLATE payload is all stored blocks: valid, and buildable
|
|
-- without a compressor.
|
|
local function gz(data)
|
|
local blocks, pos = {}, 1
|
|
repeat
|
|
local chunk = data:sub(pos, pos + 65534)
|
|
pos = pos + #chunk
|
|
local final = pos > #data and 1 or 0
|
|
table.insert(blocks, string.char(final) .. le(#chunk, 2) .. le(65535 - #chunk, 2) .. chunk)
|
|
until pos > #data
|
|
return '\31\139\8\0\0\0\0\0\0\3' .. table.concat(blocks) .. le(zzlib.crc32(data), 4) .. le(#data, 4)
|
|
end
|
|
|
|
local function tar_header(name, size, kind, mode)
|
|
local h = name .. string.rep('\0', 100 - #name)
|
|
h = h .. string.format('%07o\0', mode or 420)
|
|
h = h .. string.format('%07o\0', 0) .. string.format('%07o\0', 0)
|
|
h = h .. string.format('%011o\0', size) .. string.format('%011o\0', 0)
|
|
h = h .. string.rep(' ', 8) .. kind .. string.rep('\0', 100)
|
|
h = h .. 'ustar\0' .. '00' .. string.rep('\0', 32) .. string.rep('\0', 32)
|
|
h = h .. string.rep('\0', 8) .. string.rep('\0', 8) .. string.rep('\0', 155)
|
|
h = h .. string.rep('\0', 512 - #h)
|
|
local sum = 0
|
|
for i = 1, 512 do
|
|
sum = sum + h:byte(i)
|
|
end
|
|
return h:sub(1, 148) .. string.format('%06o\0 ', sum) .. h:sub(157)
|
|
end
|
|
|
|
local function tarf(entries)
|
|
local out = {}
|
|
for _, e in ipairs(entries) do
|
|
local data = e.data or ''
|
|
table.insert(out, tar_header(e.name, #data, e.kind or '0', e.mode))
|
|
if #data > 0 then
|
|
table.insert(out, data .. string.rep('\0', (512 - #data % 512) % 512))
|
|
end
|
|
end
|
|
table.insert(out, string.rep('\0', 1024))
|
|
return table.concat(out)
|
|
end
|
|
|
|
local function write(path, data)
|
|
local fd = assert(io.open(path, 'wb'))
|
|
fd:write(data)
|
|
fd:close()
|
|
return path
|
|
end
|
|
|
|
local function read(path)
|
|
local fd = io.open(path, 'rb')
|
|
if not fd then
|
|
return nil
|
|
end
|
|
local data = fd:read('*a')
|
|
fd:close()
|
|
return data
|
|
end
|
|
|
|
local function extract(bytes, ext)
|
|
local dir = tmpdir()
|
|
local src = write(dir .. '/archive', bytes)
|
|
local dest = dir .. '/out'
|
|
local ok, err = archive.extract(src, dest, ext or 'zip')
|
|
return ok, err, dest
|
|
end
|
|
|
|
t.describe('archive.safe_path', function()
|
|
t.it('accepts an ordinary nested path', function()
|
|
t.eq('plugin/lua/init.lua', archive.safe_path('plugin/lua/init.lua'))
|
|
end)
|
|
|
|
t.it('collapses redundant separators and dot components', function()
|
|
t.eq('a/b.lua', archive.safe_path('./a//./b.lua'))
|
|
end)
|
|
|
|
t.it('rejects a parent-directory component', function()
|
|
local ok, err = archive.safe_path('a/../../etc/passwd')
|
|
t.falsy(ok)
|
|
t.matches('escapes', err)
|
|
end)
|
|
|
|
t.it('rejects a leading parent-directory component', function()
|
|
t.falsy(archive.safe_path('../evil'))
|
|
end)
|
|
|
|
t.it('rejects an absolute path', function()
|
|
local ok, err = archive.safe_path('/etc/passwd')
|
|
t.falsy(ok)
|
|
t.matches('absolute', err)
|
|
end)
|
|
|
|
t.it('rejects a windows drive path', function()
|
|
local ok, err = archive.safe_path('C:/windows/system32')
|
|
t.falsy(ok)
|
|
t.matches('drive', err)
|
|
end)
|
|
|
|
t.it('rejects a backslash separator', function()
|
|
t.falsy(archive.safe_path('a\\..\\..\\evil'))
|
|
end)
|
|
|
|
t.it('rejects an embedded NUL', function()
|
|
t.falsy(archive.safe_path('a\0b'))
|
|
end)
|
|
|
|
t.it('rejects a name that resolves to nothing', function()
|
|
t.falsy(archive.safe_path('./'))
|
|
end)
|
|
end)
|
|
|
|
t.describe('archive.extract zip', function()
|
|
t.it('writes nested files, empty files and directories', function()
|
|
local ok, err, dest = extract(zipf({
|
|
{ name = 'root/' },
|
|
{ name = 'root/init.lua', data = 'return {}' },
|
|
{ name = 'root/empty.txt', data = '' },
|
|
{ name = 'root/lua/deep/mod.lua', data = 'deep' },
|
|
{ name = 'root/noext', data = 'x' },
|
|
{ name = 'root/with space.txt', data = 'y' },
|
|
{ name = 'root/ünïcødé.txt', data = 'z' },
|
|
}))
|
|
t.truthy(ok, err)
|
|
t.eq('return {}', read(dest .. '/root/init.lua'))
|
|
t.eq('', read(dest .. '/root/empty.txt'))
|
|
t.eq('deep', read(dest .. '/root/lua/deep/mod.lua'))
|
|
t.eq('x', read(dest .. '/root/noext'))
|
|
t.eq('y', read(dest .. '/root/with space.txt'))
|
|
t.eq('z', read(dest .. '/root/ünïcødé.txt'))
|
|
end)
|
|
|
|
t.it('reads an archive that carries a trailing comment', function()
|
|
local bytes = zipf({ { name = 'root/a.lua', data = 'hi' }, comment = 'built by CI PK\5\6 not really' })
|
|
local ok, err, dest = extract(bytes)
|
|
t.truthy(ok, err)
|
|
t.eq('hi', read(dest .. '/root/a.lua'))
|
|
end)
|
|
|
|
t.it('carries the executable bit but drops setuid', function()
|
|
local uv = vim.uv or vim.loop
|
|
local ok, err, dest = extract(zipf({
|
|
{ name = 'root/run.sh', data = '#!/bin/sh\n', attr = (0x8000 + 0x800 + 0x1ed) * 65536 },
|
|
}))
|
|
t.truthy(ok, err)
|
|
t.eq(0x1ed, uv.fs_stat(dest .. '/root/run.sh').mode % 0x1000)
|
|
end)
|
|
|
|
t.it('refuses an entry that walks out of the destination', function()
|
|
local ok, err = extract(zipf({ { name = '../evil.lua', data = 'pwned' } }))
|
|
t.falsy(ok)
|
|
t.matches('escapes', err)
|
|
end)
|
|
|
|
t.it('refuses an absolute entry', function()
|
|
local ok, err = extract(zipf({ { name = '/tmp/evil.lua', data = 'pwned' } }))
|
|
t.falsy(ok)
|
|
t.matches('absolute', err)
|
|
end)
|
|
|
|
t.it('refuses a symlink entry', function()
|
|
local ok, err = extract(zipf({ { name = 'root/link', data = '/etc', attr = (0xa000 + 0x1ff) * 65536 } }))
|
|
t.falsy(ok)
|
|
t.matches('not a regular file', err)
|
|
end)
|
|
|
|
t.it('refuses an encrypted entry', function()
|
|
local ok, err = extract(zipf({ { name = 'root/a.lua', data = 'x', flags = 1 } }))
|
|
t.falsy(ok)
|
|
t.matches('encrypted', err)
|
|
end)
|
|
|
|
t.it('fails loudly on a CRC mismatch', function()
|
|
local ok, err = extract(zipf({ { name = 'root/a.lua', data = 'hello', crc = 12345 } }))
|
|
t.falsy(ok)
|
|
t.matches('CRC mismatch', err)
|
|
end)
|
|
|
|
t.it('fails on a truncated archive', function()
|
|
local bytes = zipf({ { name = 'root/a.lua', data = 'hello' } })
|
|
local ok, err = extract(bytes:sub(1, #bytes - 40))
|
|
t.falsy(ok)
|
|
t.matches('end%-of%-central%-directory', err)
|
|
end)
|
|
|
|
t.it('fails on random bytes with a zip magic', function()
|
|
local ok = extract('PK\3\4' .. string.rep('\200', 400))
|
|
t.falsy(ok)
|
|
end)
|
|
end)
|
|
|
|
t.describe('archive.extract tar.gz', function()
|
|
t.it('writes files and directories', function()
|
|
local ok, err, dest = extract(
|
|
gz(tarf({
|
|
{ name = 'root/', kind = '5' },
|
|
{ name = 'root/init.lua', data = 'return {}' },
|
|
{ name = 'root/empty.txt', data = '' },
|
|
{ name = 'root/lua/deep/mod.lua', data = 'deep' },
|
|
})),
|
|
'tar.gz'
|
|
)
|
|
t.truthy(ok, err)
|
|
t.eq('return {}', read(dest .. '/root/init.lua'))
|
|
t.eq('', read(dest .. '/root/empty.txt'))
|
|
t.eq('deep', read(dest .. '/root/lua/deep/mod.lua'))
|
|
end)
|
|
|
|
t.it('skips a pax global header', function()
|
|
local ok, err, dest = extract(
|
|
gz(tarf({
|
|
{ name = 'pax_global_header', kind = 'g', data = '20 comment=deadbeef\n' },
|
|
{ name = 'root/init.lua', data = 'ok' },
|
|
})),
|
|
'tar.gz'
|
|
)
|
|
t.truthy(ok, err)
|
|
t.eq('ok', read(dest .. '/root/init.lua'))
|
|
end)
|
|
|
|
t.it('honours a pax path override', function()
|
|
local path = 'root/a-very-long-name.lua'
|
|
-- A pax record's length field counts itself, so it has to reach a fixed point.
|
|
local len = #path + 7
|
|
while #tostring(len) + #path + 7 ~= len do
|
|
len = #tostring(len) + #path + 7
|
|
end
|
|
local record = string.format('%d path=%s\n', len, path)
|
|
local ok, err, dest = extract(
|
|
gz(tarf({
|
|
{ name = 'short', kind = 'x', data = record },
|
|
{ name = 'short', data = 'long' },
|
|
})),
|
|
'tar.gz'
|
|
)
|
|
t.truthy(ok, err)
|
|
t.eq('long', read(dest .. '/' .. path))
|
|
end)
|
|
|
|
t.it('refuses an entry that walks out of the destination', function()
|
|
local ok, err = extract(gz(tarf({ { name = '../evil.lua', data = 'pwned' } })), 'tar.gz')
|
|
t.falsy(ok)
|
|
t.matches('escapes', err)
|
|
end)
|
|
|
|
t.it('refuses a symlink entry', function()
|
|
local ok, err = extract(gz(tarf({ { name = 'root/link', kind = '2' } })), 'tar.gz')
|
|
t.falsy(ok)
|
|
t.matches('unsupported tar entry type', err)
|
|
end)
|
|
|
|
t.it('fails loudly on a corrupt gzip payload', function()
|
|
local bytes = gz(tarf({ { name = 'root/a.lua', data = 'hello' } }))
|
|
local flipped = bytes:sub(1, 40) .. string.char((bytes:byte(41) + 1) % 256) .. bytes:sub(42)
|
|
local ok, err = extract(flipped, 'tar.gz')
|
|
t.falsy(ok)
|
|
t.matches('gunzip failed', err)
|
|
end)
|
|
|
|
t.it('fails on a corrupt tar header', function()
|
|
local tar = tarf({ { name = 'root/a.lua', data = 'hello' } })
|
|
local ok, err = extract(gz('X' .. tar:sub(2)), 'tar.gz')
|
|
t.falsy(ok)
|
|
t.matches('checksum mismatch', err)
|
|
end)
|
|
end)
|