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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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*
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user