Files
arti-pack/tests/archive_spec.lua
T
unkin-agent 5809a843c8
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Extract archives in pure Lua
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.
2026-09-10 22:48:25 +10:00

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)