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
+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)