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)