Files
arti-pack/tests/archive_spec.lua
T
unkin-agent 78a5df6cf3 Test the DEFLATE path with real compressed fixtures
The fixture builder only produced stored entries, so the vendored inflate
never ran under test. Add a small DEFLATE encoder and drive zip and
tar.gz entries through it: round-trips for fixed and dynamic huffman,
empty and large payloads, plus wrong CRC, wrong stated size, truncated
and corrupted streams, and a traversal name on a compressed entry.

Also cover the central directory and local header truncations that used
to raise.
2026-09-10 23:27:22 +10:00

625 lines
21 KiB
Lua

local t = require('tests.harness')
local archive = require('arti-pack.archive')
local deflate = require('tests.deflate')
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. `deflate = true` runs the
-- payload through a real compressor, so DEFLATE entries are not approximated
-- and the tests cannot skip themselves when no zip binary is around.
local function zipf(entries)
local locals, central, offset = {}, {}, 0
for _, e in ipairs(entries) do
local data = e.data or ''
local method, payload = 0, data
if e.deflate then
method, payload = 8, deflate.compress(data, e.mode)
end
method = e.method or method
if e.mangle then
payload = e.mangle(payload)
end
local crc = e.crc or zzlib.crc32(data)
local csize, usize = #payload, e.usize or #data
local head = 'PK\3\4'
.. le(20, 2)
.. le(e.flags or 0, 2)
.. le(method, 2)
.. le(0, 4)
.. le(crc, 4)
.. le(csize, 4)
.. le(usize, 4)
.. le(#e.name, 2)
.. le(0, 2)
.. e.name
table.insert(locals, head .. payload)
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(method, 2)
.. le(0, 4)
.. le(crc, 4)
.. le(csize, 4)
.. le(usize, 4)
.. le(#e.name, 2)
.. le(0, 2)
.. le(0, 2)
.. le(0, 2)
.. le(0, 2)
.. le(e.attr or 0, 4)
.. le(e.local_offset or offset, 4)
.. e.name
)
offset = offset + #head + #payload
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
local function stored_blocks(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 table.concat(blocks)
end
-- A gzip member. Without a mode the DEFLATE payload is all stored blocks; with
-- one it is genuinely compressed, which is what drives the huffman decoder.
local function gz(data, mode)
local payload = mode and deflate.compress(data, mode) or stored_blocks(data)
return '\31\139\8\0\0\0\0\0\0\3' .. payload .. 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
-- Deterministic pseudo-random bytes, so a compressed fixture is the same on
-- every run but still spans the whole literal alphabet.
local function noise(n, seed)
local out, state = {}, seed
for _ = 1, n do
state = (state * 1103515245 + 12345) % 2147483648
table.insert(out, string.char(math.floor(state / 65536) % 256))
end
return table.concat(out)
end
-- Every byte value, then repeated text: literal codes of both widths plus real
-- back-references.
local function sample()
local parts = {}
for i = 0, 255 do
table.insert(parts, string.char(i))
end
for i = 1, 40 do
table.insert(parts, string.format('local module_%d = require("arti-pack.mod")\n', i))
end
return table.concat(parts)
end
local function patch32(bytes, at, value)
return bytes:sub(1, at - 1) .. le(value, 4) .. bytes:sub(at + 4)
end
local function flip(bytes, at, count)
local out = bytes:sub(1, at - 1)
for i = at, at + count - 1 do
out = out .. string.char((bytes:byte(i) + 137) % 256)
end
return out .. bytes:sub(at + count)
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)
t.it('rejects an unsupported compression method', function()
local ok, err = extract(zipf({ { name = 'root/a.lua', data = 'x', method = 12 } }))
t.falsy(ok)
t.matches('unsupported compression method 12', err)
end)
end)
t.describe('archive.extract zip malformed directory', function()
-- A central directory record that stops before its extra-field and comment
-- lengths used to raise instead of reporting.
local function truncate_cd(keep)
local bytes = zipf({ { name = 'root/a.lua', data = 'hi' } })
local cd = bytes:find('PK\1\2', 1, true)
local eocd = bytes:find('PK\5\6', cd, true)
return bytes:sub(1, cd + keep - 1) .. bytes:sub(eocd)
end
t.it('reports a record cut off before its length fields', function()
local ok, err = extract(truncate_cd(30))
t.falsy(ok)
t.matches('truncated central directory', err)
end)
t.it('reports a record cut off just after its signature', function()
local ok, err = extract(truncate_cd(4))
t.falsy(ok)
t.matches('truncated central directory', err)
end)
t.it('reports a local header that runs off the end of the archive', function()
-- The trailing comment plants a local-header signature in the last four
-- bytes; the entry is pointed at it.
local bytes = zipf({ { name = 'root/a.lua', data = 'hi' }, comment = 'PK\3\4' })
local cd = bytes:find('PK\1\2', 1, true)
local ok, err = extract(patch32(bytes, cd + 42, #bytes - 4))
t.falsy(ok)
t.matches('truncated local header', err)
end)
end)
t.describe('archive.extract zip deflate', function()
local payload = sample()
t.it('round-trips a dynamic-huffman entry byte for byte', function()
local ok, err, dest = extract(zipf({ { name = 'root/a.lua', data = payload, deflate = true } }))
t.truthy(ok, err)
t.eq(payload, read(dest .. '/root/a.lua'))
end)
t.it('round-trips a fixed-huffman entry byte for byte', function()
local bytes = zipf({ { name = 'root/a.lua', data = payload, deflate = true, mode = 'fixed' } })
local ok, err, dest = extract(bytes)
t.truthy(ok, err)
t.eq(payload, read(dest .. '/root/a.lua'))
end)
t.it('round-trips deflated entries alongside stored ones', function()
local overlapping = string.rep('ab', 3000)
local ok, err, dest = extract(zipf({
{ name = 'root/' },
{ name = 'root/stored.lua', data = 'return {}' },
{ name = 'root/packed.lua', data = payload, deflate = true },
{ name = 'root/nested/overlap.txt', data = overlapping, deflate = true },
}))
t.truthy(ok, err)
t.eq('return {}', read(dest .. '/root/stored.lua'))
t.eq(payload, read(dest .. '/root/packed.lua'))
t.eq(overlapping, read(dest .. '/root/nested/overlap.txt'))
end)
t.it('round-trips a deflated entry that decompresses to nothing', function()
local ok, err, dest = extract(zipf({ { name = 'root/empty.lua', data = '', deflate = true } }))
t.truthy(ok, err)
t.eq('', read(dest .. '/root/empty.lua'))
end)
t.it('round-trips a large deflated entry', function()
local big = string.rep(noise(1024, 11), 400)
local bytes = zipf({ { name = 'root/big.bin', data = big, deflate = true } })
t.truthy(#bytes < #big / 4, 'the fixture is not actually compressed')
local ok, err, dest = extract(bytes)
t.truthy(ok, err)
local out = read(dest .. '/root/big.bin')
t.eq(#big, #out)
t.truthy(out == big, 'the large entry did not round-trip exactly')
end)
t.it('fails loudly when a deflated entry carries the wrong CRC', function()
local ok, err, dest = extract(zipf({ { name = 'root/a.lua', data = payload, deflate = true, crc = 12345 } }))
t.falsy(ok)
t.matches('checksum', err)
t.falsy(read(dest .. '/root/a.lua'), 'nothing should be written')
end)
t.it('fails loudly when a deflated entry disagrees with the stated size', function()
local bytes = zipf({ { name = 'root/a.lua', data = payload, deflate = true, usize = #payload + 1 } })
local ok, err, dest = extract(bytes)
t.falsy(ok)
t.matches('does not match the directory', err)
t.falsy(read(dest .. '/root/a.lua'), 'nothing should be written')
end)
t.it('fails loudly on a truncated deflate stream', function()
local ok, err, dest = extract(zipf({
{
name = 'root/a.lua',
data = payload,
deflate = true,
mangle = function(s)
return s:sub(1, math.floor(#s / 2))
end,
},
}))
t.falsy(ok)
t.matches('root/a%.lua', err)
t.falsy(read(dest .. '/root/a.lua'), 'a partial inflate must not reach disk')
end)
t.it('fails loudly on a deflate stream corrupted in the middle', function()
local ok, err, dest = extract(zipf({
{
name = 'root/a.lua',
data = payload,
deflate = true,
mangle = function(s)
return flip(s, math.floor(#s / 2), 4)
end,
},
}))
t.falsy(ok)
t.matches('root/a%.lua', err)
t.falsy(read(dest .. '/root/a.lua'), 'a corrupt inflate must not reach disk')
end)
t.it('refuses a deflated entry that walks out of the destination', function()
local ok, err, dest = extract(zipf({ { name = '../evil.lua', data = payload, deflate = true } }))
t.falsy(ok)
t.matches('escapes', err)
t.falsy(read(dest .. '/../evil.lua'), 'nothing should be written')
end)
t.it('refuses a deflated symlink entry', function()
local ok, err = extract(zipf({
{ name = 'root/link', data = string.rep('/etc/passwd', 40), deflate = true, attr = (0xa000 + 0x1ff) * 65536 },
}))
t.falsy(ok)
t.matches('not a regular file', err)
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)
-- The stored-block fixtures above never reach the huffman decoder, so the
-- compressed cases are covered separately.
t.describe('archive.extract tar.gz deflate', function()
local payload = sample()
t.it('inflates a dynamic-huffman member', function()
local bytes = gz(tarf({ { name = 'root/a.lua', data = payload } }), 'dynamic')
local ok, err, dest = extract(bytes, 'tar.gz')
t.truthy(ok, err)
t.eq(payload, read(dest .. '/root/a.lua'))
end)
t.it('inflates a fixed-huffman member', function()
local bytes = gz(tarf({ { name = 'root/a.lua', data = payload } }), 'fixed')
local ok, err, dest = extract(bytes, 'tar.gz')
t.truthy(ok, err)
t.eq(payload, read(dest .. '/root/a.lua'))
end)
t.it('inflates an empty member', function()
local bytes = gz(tarf({ { name = 'root/empty.lua', data = '' } }), 'dynamic')
local ok, err, dest = extract(bytes, 'tar.gz')
t.truthy(ok, err)
t.eq('', read(dest .. '/root/empty.lua'))
end)
t.it('inflates a large member', function()
local big = string.rep(noise(1024, 29), 300)
local bytes = gz(tarf({ { name = 'root/big.bin', data = big } }), 'dynamic')
t.truthy(#bytes < #big / 4, 'the fixture is not actually compressed')
local ok, err, dest = extract(bytes, 'tar.gz')
t.truthy(ok, err)
local out = read(dest .. '/root/big.bin')
t.eq(#big, #out)
t.truthy(out == big, 'the large member did not round-trip exactly')
end)
t.it('fails loudly when the gzip checksum disagrees', function()
local bytes = gz(tarf({ { name = 'root/a.lua', data = payload } }), 'dynamic')
local ok, err, dest = extract(patch32(bytes, #bytes - 7, 12345), 'tar.gz')
t.falsy(ok)
t.matches('gunzip failed', err)
t.falsy(read(dest .. '/root/a.lua'), 'nothing should be written')
end)
t.it('fails loudly on a truncated compressed member', function()
local bytes = gz(tarf({ { name = 'root/a.lua', data = payload } }), 'dynamic')
local ok, err, dest = extract(bytes:sub(1, 10 + math.floor((#bytes - 18) / 2)), 'tar.gz')
t.falsy(ok)
t.matches('gunzip failed', err)
t.falsy(read(dest .. '/root/a.lua'), 'a partial inflate must not reach disk')
end)
t.it('fails loudly on a member corrupted in the middle', function()
local bytes = gz(tarf({ { name = 'root/a.lua', data = payload } }), 'dynamic')
local ok, err, dest = extract(flip(bytes, math.floor(#bytes / 2), 4), 'tar.gz')
t.falsy(ok)
t.matches('gunzip failed', err)
t.falsy(read(dest .. '/root/a.lua'), 'a corrupt inflate must not reach disk')
end)
t.it('refuses a hostile path inside a compressed member', function()
local bytes = gz(tarf({ { name = '../evil.lua', data = payload } }), 'dynamic')
local ok, err, dest = extract(bytes, 'tar.gz')
t.falsy(ok)
t.matches('escapes', err)
t.falsy(read(dest .. '/../evil.lua'), 'nothing should be written')
end)
t.it('stops a pax record that claims a zero length', function()
local bytes = gz(
tarf({
{ name = 'root/pax', kind = 'x', data = '0 path=root/a.lua\n' },
{ name = 'root/a.lua', data = 'ok' },
}),
'dynamic'
)
local ok, err, dest = extract(bytes, 'tar.gz')
t.truthy(ok, err)
t.eq('ok', read(dest .. '/root/a.lua'))
end)
end)