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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user