Extract archives in pure Lua
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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.
This commit is contained in:
2026-09-10 22:48:25 +10:00
parent bae3a517f7
commit 5809a843c8
11 changed files with 753 additions and 28 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ steps:
- name: test
image: alpine:3.22
commands:
- apk add --no-cache neovim curl unzip tar zip make
# zip and tar build fixture archives; nothing unpacks them but arti-pack.
- apk add --no-cache neovim curl tar zip make
- make test
backend_options:
kubernetes:
+5 -1
View File
@@ -14,7 +14,11 @@ from.
## Requirements
- Neovim 0.10+
- `curl`, and `unzip` (or `tar` when using `ext = 'tar.gz'`)
- `curl`
Archives are unpacked in pure Lua, so no `unzip` or `tar` binary is needed.
Entry paths are validated and every entry's CRC32 is checked before it lands on
disk.
## Install
+3 -1
View File
@@ -21,7 +21,9 @@ works — a JFrog Artifactory remote GitHub repository, or any caching proxy
with the same path shape. Nothing is cloned and the editor never reaches
GitHub itself.
Requires curl, and unzip or tar.
Requires curl. Zip and tar.gz archives are unpacked in pure Lua, so no
unzip or tar binary is needed. Entry paths are validated and every
entry's CRC32 is checked before it lands on disk.
==============================================================================
2. SETUP *arti-pack-setup*
+373
View File
@@ -0,0 +1,373 @@
local uv = vim.uv or vim.loop
local zzlib = require('arti-pack.vendor.zzlib')
local M = {}
local EOCD_SIG = 0x06054b50
local EOCD64_LOCATOR_SIG = 0x07064b50
local CD_SIG = 0x02014b50
local LOCAL_SIG = 0x04034b50
local MAX_EOCD_SCAN = 66000
local STORED, DEFLATED = 0, 8
-- Unix file-type bits sit above the low 12 permission bits of the mode.
local S_IFMT_UNIT, S_IFREG, S_IFDIR = 0x1000, 0x8000, 0x4000
local MODE_EXEC, MODE_FILE, MODE_EXEC_FILE = 0x40, 420, 493
local TAR_BLOCK = 512
local function u16(buf, pos)
local a, b = buf:byte(pos, pos + 1)
if not b then
return nil
end
return b * 256 + a
end
local function u32(buf, pos)
local a, b, c, d = buf:byte(pos, pos + 3)
if not d then
return nil
end
return ((d * 256 + c) * 256 + b) * 256 + a
end
-- Every write goes through here. Entry names come from the archive, so they are
-- attacker-controlled: anything that is absolute, walks upwards, or carries a
-- Windows drive or separator is refused rather than sanitised, so a hostile
-- archive fails the install instead of quietly landing files somewhere else.
function M.safe_path(name)
if type(name) ~= 'string' or name == '' then
return nil, 'empty entry name'
end
if name:find('\\') then
return nil, 'backslash in entry name'
end
if name:find('%z') then
return nil, 'NUL in entry name'
end
if name:sub(1, 1) == '/' then
return nil, 'absolute entry name'
end
if name:match('^%a:') then
return nil, 'drive-qualified entry name'
end
local parts = {}
for part in name:gmatch('[^/]+') do
if part == '..' then
return nil, 'entry name escapes the destination'
elseif part ~= '.' then
table.insert(parts, part)
end
end
if #parts == 0 then
return nil, 'entry name resolves to nothing'
end
return table.concat(parts, '/')
end
local function reject(name, why)
return nil, string.format('%s: %s', name, why)
end
local function mkdirp(path)
vim.fn.mkdir(path, 'p')
end
local function write_file(dest, rel, data, executable)
local path = dest .. '/' .. rel
local parent = path:match('^(.*)/[^/]+$')
if parent then
mkdirp(parent)
end
local fd, err = io.open(path, 'wb')
if not fd then
return nil, string.format('%s: %s', rel, tostring(err))
end
fd:write(data)
fd:close()
-- Archive modes are advisory; only the executable bit is carried over so a
-- setuid or world-writable entry cannot land on disk with those bits set.
uv.fs_chmod(path, executable and MODE_EXEC_FILE or MODE_FILE)
return true
end
local function unix_mode(version_made_by, external_attr)
if math.floor(version_made_by / 256) ~= 3 then
return nil
end
return math.floor(external_attr / 65536) % 65536
end
-- Scanned backwards because the record is followed by a variable-length comment.
-- The signature can occur inside that comment, so a candidate only counts if its
-- central directory offset actually lands on a central directory.
local function find_eocd(buf)
local limit = math.min(#buf, MAX_EOCD_SCAN)
for back = 22, limit do
local pos = #buf - back + 1
if u32(buf, pos) == EOCD_SIG then
local count = u16(buf, pos + 10)
local cd = u32(buf, pos + 16) + 1
if count == 0 or u32(buf, cd) == CD_SIG then
return pos
end
end
end
return nil
end
local function zip_entries(buf)
if #buf < 22 then
return nil, 'archive is too small to be a zip'
end
local eocd = find_eocd(buf)
if not eocd then
return nil, 'no end-of-central-directory record found'
end
if eocd >= 20 and u32(buf, eocd - 20) == EOCD64_LOCATOR_SIG then
return nil, 'zip64 archives are not supported'
end
local count = u16(buf, eocd + 10)
local cd = u32(buf, eocd + 16) + 1
if count == 0xffff or cd == 0x100000000 then
return nil, 'zip64 archives are not supported'
end
local entries, pos = {}, cd
for _ = 1, count do
if u32(buf, pos) ~= CD_SIG then
return nil, 'malformed central directory'
end
local namelen = u16(buf, pos + 28)
local entry = {
version_made_by = u16(buf, pos + 4),
flags = u16(buf, pos + 8),
method = u16(buf, pos + 10),
crc = u32(buf, pos + 16),
csize = u32(buf, pos + 20),
usize = u32(buf, pos + 24),
external_attr = u32(buf, pos + 38),
local_offset = u32(buf, pos + 42),
name = buf:sub(pos + 46, pos + 45 + namelen),
}
if #entry.name ~= namelen then
return nil, 'truncated central directory'
end
table.insert(entries, entry)
pos = pos + 46 + namelen + u16(buf, pos + 30) + u16(buf, pos + 32)
end
return entries
end
local function zip_data(buf, entry)
local lho = entry.local_offset + 1
if u32(buf, lho) ~= LOCAL_SIG then
return reject(entry.name, 'invalid local header signature')
end
local start = lho + 30 + u16(buf, lho + 26) + u16(buf, lho + 28)
local data
if entry.method == STORED then
data = buf:sub(start, start + entry.csize - 1)
if #data ~= entry.csize then
return reject(entry.name, 'truncated stored entry')
end
if zzlib.crc32(data) ~= entry.crc then
return reject(entry.name, 'CRC mismatch')
end
else
-- zzlib verifies the CRC itself and raises on mismatch.
local ok, result = pcall(zzlib.unzip, buf, start, entry.crc)
if not ok then
return reject(entry.name, tostring(result):gsub('^.-:%d+: ', ''))
end
data = result
end
if #data ~= entry.usize then
return reject(entry.name, 'decompressed size does not match the directory')
end
return data
end
function M.unzip(buf, dest)
local entries, err = zip_entries(buf)
if not entries then
return nil, err
end
for _, entry in ipairs(entries) do
if entry.flags % 2 == 1 then
return reject(entry.name, 'encrypted entries are not supported')
end
if entry.method ~= STORED and entry.method ~= DEFLATED then
return reject(entry.name, string.format('unsupported compression method %d', entry.method))
end
local mode = unix_mode(entry.version_made_by, entry.external_attr)
local kind = mode and mode - mode % S_IFMT_UNIT
-- A symlink entry is how an archive smuggles a later write out of the
-- destination, so anything that is not a plain file or directory is fatal.
if kind and kind ~= 0 and kind ~= S_IFREG and kind ~= S_IFDIR then
return reject(entry.name, 'entry is not a regular file or directory')
end
local rel, perr = M.safe_path(entry.name)
if not rel then
return reject(entry.name, perr)
end
if entry.name:sub(-1) == '/' then
mkdirp(dest .. '/' .. rel)
else
local data, derr = zip_data(buf, entry)
if not data then
return nil, derr
end
local ok, werr = write_file(dest, rel, data, mode ~= nil and mode % (MODE_EXEC * 2) >= MODE_EXEC)
if not ok then
return nil, werr
end
end
end
return true
end
local function octal(field)
local digits = field:match('^[ ]*([0-7]*)')
if not digits or digits == '' then
return 0
end
return tonumber(digits, 8)
end
local function tar_checksum_ok(header)
local stored = octal(header:sub(149, 156))
local signed, unsigned = 0, 0
for i = 1, TAR_BLOCK do
local c = i >= 149 and i <= 156 and 32 or header:byte(i)
unsigned = unsigned + c
signed = signed + (c > 127 and c - 256 or c)
end
return stored == unsigned or stored == signed
end
local function pax_records(block)
local out, pos = {}, 1
while pos <= #block do
local len, rest = block:match('^(%d+) ()', pos)
if not len then
break
end
len = tonumber(len)
local record = block:sub(rest, pos + len - 2)
local key, value = record:match('^([^=]+)=(.*)$')
if key then
out[key] = value
end
pos = pos + len
end
return out
end
function M.untar(buf, dest)
local pos, zeros = 1, 0
local override
while pos + TAR_BLOCK - 1 <= #buf do
local header = buf:sub(pos, pos + TAR_BLOCK - 1)
pos = pos + TAR_BLOCK
if header:match('^%z*$') then
zeros = zeros + 1
if zeros == 2 then
return true
end
else
zeros = 0
if not tar_checksum_ok(header) then
return nil, 'tar header checksum mismatch'
end
local size_field = header:sub(125, 136)
if size_field:byte(1) and size_field:byte(1) >= 128 then
return nil, 'base-256 tar sizes are not supported'
end
local size = octal(size_field)
local kind = header:sub(157, 157)
local blocks = math.ceil(size / TAR_BLOCK) * TAR_BLOCK
local body = buf:sub(pos, pos + size - 1)
if #body ~= size then
return nil, 'truncated tar entry'
end
pos = pos + blocks
local name = header:sub(1, 100):gsub('%z.*$', '')
local prefix = header:sub(346, 500):gsub('%z.*$', '')
if prefix ~= '' then
name = prefix .. '/' .. name
end
if override then
name, override = override, nil
end
if kind == 'g' then -- global pax header, carries no per-entry path
override = nil
elseif kind == 'x' then
override = pax_records(body).path
elseif kind == 'L' then
override = body:gsub('%z.*$', '')
elseif kind == 'K' then
override = nil -- long link name; the link entry itself is rejected below
elseif kind == '0' or kind == '\0' or kind == '5' then
local rel, perr = M.safe_path(name)
if not rel then
return reject(name, perr)
end
if kind == '5' then
mkdirp(dest .. '/' .. rel)
else
local mode = octal(header:sub(101, 108))
local ok, werr = write_file(dest, rel, body, mode % (MODE_EXEC * 2) >= MODE_EXEC)
if not ok then
return nil, werr
end
end
else
-- Symlinks, hardlinks and device nodes have no business in a plugin
-- archive and are the usual vehicle for escaping the destination.
return reject(name, string.format('unsupported tar entry type %q', kind))
end
end
end
return nil, 'tar archive ended without an end-of-archive marker'
end
function M.extract(path, dest, ext)
local fd, oerr = io.open(path, 'rb')
if not fd then
return nil, tostring(oerr)
end
local buf = fd:read('*a')
fd:close()
if not buf or buf == '' then
return nil, 'archive is empty'
end
mkdirp(dest)
if ext == 'zip' then
return M.unzip(buf, dest)
end
local ok, tar = pcall(zzlib.gunzip, buf)
if not ok then
return nil, 'gunzip failed: ' .. tostring(tar):gsub('^.-:%d+: ', '')
end
return M.untar(tar, dest)
end
return M
+7 -4
View File
@@ -74,11 +74,14 @@ function M.verify_archive(path, ext)
return true
end
function M.extract_cmd(archive, dest, ext)
if ext == 'zip' then
return { 'unzip', '-q', '-o', archive, '-d', dest }
-- Extraction is pure Lua: no unzip or tar on PATH, and entry paths are vetted
-- here rather than trusted to an external tool.
function M.extract(archive, dest, ext)
local ok, res, err = pcall(require('arti-pack.archive').extract, archive, dest, ext)
if not ok then
return nil, tostring(res)
end
return { 'tar', '-xzf', archive, '-C', dest }
return res, err
end
function M.rename(from, to)
+8 -11
View File
@@ -58,17 +58,14 @@ local function extract(ctx, spec, archive, done)
fs.rm_rf(stage)
fs.mkdirp(stage)
vim.system(fs.extract_cmd(archive, stage, spec.ext), { text = true }, function(out)
vim.schedule(function()
if out.code ~= 0 then
fs.rm_rf(stage)
-- A corrupt cache entry would fail forever; drop it so the next sync refetches.
fs.rm_rf(archive)
return done(result(spec, 'failed', shell_error('extract', out)))
end
place(ctx, spec, stage, done)
end)
end)
local ok, err = fs.extract(archive, stage, spec.ext)
if not ok then
fs.rm_rf(stage)
-- A corrupt cache entry would fail forever; drop it so the next sync refetches.
fs.rm_rf(archive)
return done(result(spec, 'failed', 'extract failed: ' .. tostring(err)))
end
place(ctx, spec, stage, done)
end
local function download(ctx, spec, archive, done)
+340
View File
@@ -0,0 +1,340 @@
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)
+10 -5
View File
@@ -74,13 +74,18 @@ t.describe('fs.verify_archive', function()
end)
end)
t.describe('fs.extract_cmd', function()
t.it('uses unzip into the destination for zips', function()
t.eq({ 'unzip', '-q', '-o', '/a.zip', '-d', '/dest' }, fs.extract_cmd('/a.zip', '/dest', 'zip'))
t.describe('fs.extract', function()
t.it('reports a missing archive instead of raising', function()
local ok, err = fs.extract(tmpdir() .. '/absent.zip', tmpdir(), 'zip')
t.falsy(ok)
t.truthy(err)
end)
t.it('uses tar for gzipped tarballs', function()
t.eq({ 'tar', '-xzf', '/a.tar.gz', '-C', '/dest' }, fs.extract_cmd('/a.tar.gz', '/dest', 'tar.gz'))
t.it('reports an empty archive', function()
local path = write(tmpdir() .. '/a.zip', '')
local ok, err = fs.extract(path, tmpdir() .. '/dest', 'zip')
t.falsy(ok)
t.matches('empty', err)
end)
end)
+2 -2
View File
@@ -251,8 +251,8 @@ t.describe('arti-pack without a registry', function()
end)
t.describe('arti-pack.sync', function()
if not have('zip') or not have('unzip') then
t.it('SKIPPED: zip/unzip unavailable', function() end)
if not have('zip') then
t.it('SKIPPED: zip unavailable to build fixtures', function() end)
return
end
+2 -2
View File
@@ -75,8 +75,8 @@ local function spec_for(source, tag, archive, extra)
end
t.describe('installer.run', function()
if not have('zip') or not have('unzip') then
t.it('SKIPPED: zip/unzip unavailable', function() end)
if not have('zip') or not have('tar') then
t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end)
return
end
+1 -1
View File
@@ -8,7 +8,7 @@ package.path = table.concat({
local t = require('tests.harness')
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do
for _, name in ipairs({ 'spec_spec', 'fs_spec', 'archive_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do
require('tests.' .. name)
end